feat(dm): хранилище и миграция звонков в беседах (Фаза 7)
Звонок в личной и групповой беседе живёт в своей таблице: в voice_states guild_id обязателен, а у беседы сервера нет. Миграция 00026 добавляет dm_calls (статус, media, длительность, причина завершения) и dm_call_participants (состояние участника и флаги микрофона, камеры и экрана); уникальный частичный индекс держит один идущий звонок на беседу. В DMParticipantProfiles сравнение с владельцем обёрнуто в COALESCE: у 1:1 dm_owner_id пуст, и NULL нельзя было прочитать в int — ручка звонка в личной беседе падала на 500. Тесты: жизненный цикл звонка в хранилище (гудки, active на втором участнике, история, повторный звонок) и выборка сторожем.
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Фаза 7: звонки в личных и групповых беседах (AGENT.md 7.8, 7.14).
|
||||||
|
--
|
||||||
|
-- Звонок — отдельная сущность беседы: у него своя комната LiveKit, своё
|
||||||
|
-- состояние участников и своя история (кто звонил, когда, сколько длился,
|
||||||
|
-- кто пропустил). В voice_states звонок не помещается: там guild_id
|
||||||
|
-- обязателен, а у личной беседы сервера нет.
|
||||||
|
--
|
||||||
|
-- Статусы звонка (dm_calls.status): ringing — идут гудки, active — хотя бы
|
||||||
|
-- двое в комнате, ended — завершён. Состояния участника
|
||||||
|
-- (dm_call_participants.state): invited — приглашён и ещё не ответил,
|
||||||
|
-- joined — в звонке, declined — отклонил, left — вышел, missed — не ответил.
|
||||||
|
CREATE TABLE dm_calls (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||||
|
initiator_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
-- media: audio | video — чем открыли звонок, клиент показывает это в истории.
|
||||||
|
media TEXT NOT NULL DEFAULT 'audio',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
-- started_at — момент, когда звонок стал active: от него считается
|
||||||
|
-- длительность разговора, а не от гудков.
|
||||||
|
started_at TEXT,
|
||||||
|
ended_at TEXT,
|
||||||
|
ended_by INTEGER REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
-- end_reason: completed | canceled | declined | missed | failed.
|
||||||
|
end_reason TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX dm_calls_channel_idx ON dm_calls (channel_id, id DESC);
|
||||||
|
-- В беседе одновременно идёт не больше одного звонка: гонку двух «позвонить»
|
||||||
|
-- закрывает сама база, а не проверка в приложении.
|
||||||
|
CREATE UNIQUE INDEX dm_calls_live_channel_idx ON dm_calls (channel_id) WHERE status <> 'ended';
|
||||||
|
|
||||||
|
CREATE TABLE dm_call_participants (
|
||||||
|
call_id INTEGER NOT NULL REFERENCES dm_calls (id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
invited_at TEXT NOT NULL,
|
||||||
|
joined_at TEXT,
|
||||||
|
left_at TEXT,
|
||||||
|
self_mute INTEGER NOT NULL DEFAULT 0,
|
||||||
|
self_deaf INTEGER NOT NULL DEFAULT 0,
|
||||||
|
camera INTEGER NOT NULL DEFAULT 0,
|
||||||
|
screen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (call_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX dm_call_participants_user_idx ON dm_call_participants (user_id, call_id DESC);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE dm_call_participants;
|
||||||
|
DROP TABLE dm_calls;
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Звонки в личных и групповых беседах (AGENT.md 7.8, Фаза 7): своя таблица, а
|
||||||
|
// не voice_states — там guild_id обязателен, а у беседы сервера нет. Комната
|
||||||
|
// LiveKit у звонка одна на звонок, имя собирает voice.CallRoomName.
|
||||||
|
|
||||||
|
// Статусы звонка.
|
||||||
|
const (
|
||||||
|
DMCallRinging = "ringing"
|
||||||
|
DMCallActive = "active"
|
||||||
|
DMCallEnded = "ended"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Состояния участника звонка.
|
||||||
|
const (
|
||||||
|
DMCallInvited = "invited"
|
||||||
|
DMCallJoined = "joined"
|
||||||
|
DMCallDeclined = "declined"
|
||||||
|
DMCallLeft = "left"
|
||||||
|
DMCallMissed = "missed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Причины завершения звонка.
|
||||||
|
const (
|
||||||
|
DMCallReasonCompleted = "completed"
|
||||||
|
DMCallReasonCanceled = "canceled"
|
||||||
|
DMCallReasonDeclined = "declined"
|
||||||
|
DMCallReasonMissed = "missed"
|
||||||
|
DMCallReasonFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// История звонков беседы: по умолчанию 50 записей на страницу, максимум 100.
|
||||||
|
const (
|
||||||
|
defaultDMCallHistory = 50
|
||||||
|
maxDMCallHistory = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// DMCall — звонок беседы: кто позвонил, когда и чем закончилось.
|
||||||
|
type DMCall struct {
|
||||||
|
ID uint64
|
||||||
|
ChannelID uint64
|
||||||
|
InitiatorID uint64
|
||||||
|
Status string
|
||||||
|
Media string
|
||||||
|
CreatedAt time.Time
|
||||||
|
StartedAt *time.Time
|
||||||
|
EndedAt *time.Time
|
||||||
|
EndedBy *uint64
|
||||||
|
EndReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMCallParticipant — участник звонка и его состояние.
|
||||||
|
type DMCallParticipant struct {
|
||||||
|
CallID uint64
|
||||||
|
UserID uint64
|
||||||
|
State string
|
||||||
|
InvitedAt time.Time
|
||||||
|
JoinedAt *time.Time
|
||||||
|
LeftAt *time.Time
|
||||||
|
SelfMute bool
|
||||||
|
SelfDeaf bool
|
||||||
|
Camera bool
|
||||||
|
Screen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMCallFlags — флаги участника: микрофон, звук, камера, шаринг экрана.
|
||||||
|
type DMCallFlags struct {
|
||||||
|
SelfMute bool
|
||||||
|
SelfDeaf bool
|
||||||
|
Camera bool
|
||||||
|
Screen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMCallWithParticipants — звонок вместе с составом: так его отдают API и
|
||||||
|
// Gateway, поэтому отдельный тип, а не два запроса у вызывающего кода.
|
||||||
|
type DMCallWithParticipants struct {
|
||||||
|
DMCall
|
||||||
|
Participants []DMCallParticipant
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMCallDurationSeconds считает длительность разговора: от момента, когда
|
||||||
|
// звонок стал active (кто-то ответил), до завершения или до текущего времени.
|
||||||
|
func (s *Store) DMCallDurationSeconds(call *DMCall) int {
|
||||||
|
if call == nil || call.StartedAt == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
end := s.now()
|
||||||
|
if call.EndedAt != nil {
|
||||||
|
end = *call.EndedAt
|
||||||
|
}
|
||||||
|
if end.Before(*call.StartedAt) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(end.Sub(*call.StartedAt).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
const dmCallColumns = `id, channel_id, initiator_id, status, media, created_at,
|
||||||
|
started_at, ended_at, ended_by, end_reason`
|
||||||
|
|
||||||
|
const dmCallParticipantColumns = `call_id, user_id, state, invited_at, joined_at, left_at,
|
||||||
|
self_mute, self_deaf, camera, screen`
|
||||||
|
|
||||||
|
// CreateDMCall открывает звонок: инициатор сразу в комнате, остальные
|
||||||
|
// приглашены. Второй одновременный звонок в беседе невозможен — его
|
||||||
|
// отклоняет уникальный индекс dm_calls_live_channel_idx.
|
||||||
|
func (s *Store) CreateDMCall(ctx context.Context, channelID, initiatorID uint64, media string, invitees []uint64) (*DMCallWithParticipants, error) {
|
||||||
|
callID := s.NextID()
|
||||||
|
now := s.Now()
|
||||||
|
if err := s.InTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
var live int
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM dm_calls WHERE channel_id = ? AND status <> ?`,
|
||||||
|
int64(channelID), DMCallEnded).Scan(&live); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if live > 0 {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO dm_calls (id, channel_id, initiator_id, status, media, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
int64(callID), int64(channelID), int64(initiatorID), DMCallRinging, media, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO dm_call_participants (call_id, user_id, state, invited_at, joined_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
int64(callID), int64(initiatorID), DMCallJoined, now, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, inviteeID := range invitees {
|
||||||
|
if inviteeID == initiatorID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO dm_call_participants (call_id, user_id, state, invited_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (call_id, user_id) DO NOTHING`,
|
||||||
|
int64(callID), int64(inviteeID), DMCallInvited, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetDMCall(ctx, callID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDMCall возвращает звонок с составом.
|
||||||
|
func (s *Store) GetDMCall(ctx context.Context, callID uint64) (*DMCallWithParticipants, error) {
|
||||||
|
row := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT `+dmCallColumns+` FROM dm_calls WHERE id = ?`, int64(callID))
|
||||||
|
call, err := scanDMCall(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
participants, err := s.listDMCallParticipants(ctx, callID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &DMCallWithParticipants{DMCall: *call, Participants: participants}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLiveDMCall возвращает идущий звонок беседы: гудки или разговор.
|
||||||
|
func (s *Store) GetLiveDMCall(ctx context.Context, channelID uint64) (*DMCallWithParticipants, error) {
|
||||||
|
row := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT `+dmCallColumns+` FROM dm_calls WHERE channel_id = ? AND status <> ? ORDER BY id DESC LIMIT 1`,
|
||||||
|
int64(channelID), DMCallEnded)
|
||||||
|
call, err := scanDMCall(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
participants, err := s.listDMCallParticipants(ctx, call.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &DMCallWithParticipants{DMCall: *call, Participants: participants}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDMCallParticipant сообщает, звали ли пользователя в этот звонок.
|
||||||
|
func (s *Store) IsDMCallParticipant(ctx context.Context, callID, userID uint64) (bool, error) {
|
||||||
|
var exists int
|
||||||
|
err := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT 1 FROM dm_call_participants WHERE call_id = ? AND user_id = ?`,
|
||||||
|
int64(callID), int64(userID)).Scan(&exists)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinDMCallParticipant переводит участника в состояние «в звонке» и
|
||||||
|
// запускает отсчёт разговора, когда в комнате оказывается второй человек.
|
||||||
|
func (s *Store) JoinDMCallParticipant(ctx context.Context, callID, userID uint64) (*DMCall, error) {
|
||||||
|
now := s.Now()
|
||||||
|
if err := s.InTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
result, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE dm_call_participants SET state = ?, joined_at = ?, left_at = NULL
|
||||||
|
WHERE call_id = ? AND user_id = ?`,
|
||||||
|
DMCallJoined, now, int64(callID), int64(userID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
var joined int
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM dm_call_participants WHERE call_id = ? AND state = ?`,
|
||||||
|
int64(callID), DMCallJoined).Scan(&joined); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if joined < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE dm_calls SET status = ?, started_at = COALESCE(started_at, ?)
|
||||||
|
WHERE id = ? AND status = ?`,
|
||||||
|
DMCallActive, now, int64(callID), DMCallRinging)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetDMCallRow(ctx, callID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaveDMCallParticipant помечает участника вышедшим, отклонившим или
|
||||||
|
// пропустившим звонок.
|
||||||
|
func (s *Store) LeaveDMCallParticipant(ctx context.Context, callID, userID uint64, state string) error {
|
||||||
|
_, err := s.writer.ExecContext(ctx, `
|
||||||
|
UPDATE dm_call_participants SET state = ?, left_at = ?
|
||||||
|
WHERE call_id = ? AND user_id = ?`,
|
||||||
|
state, s.Now(), int64(callID), int64(userID))
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountDMCallParticipants считает участников звонка в заданном состоянии.
|
||||||
|
func (s *Store) CountDMCallParticipants(ctx context.Context, callID uint64, state string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM dm_call_participants WHERE call_id = ? AND state = ?`,
|
||||||
|
int64(callID), state).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDMCallFlags сохраняет флаги участника (микрофон, звук, камера, экран).
|
||||||
|
func (s *Store) UpdateDMCallFlags(ctx context.Context, callID, userID uint64, flags DMCallFlags) error {
|
||||||
|
result, err := s.writer.ExecContext(ctx, `
|
||||||
|
UPDATE dm_call_participants
|
||||||
|
SET self_mute = ?, self_deaf = ?, camera = ?, screen = ?
|
||||||
|
WHERE call_id = ? AND user_id = ?`,
|
||||||
|
boolToInt(flags.SelfMute), boolToInt(flags.SelfDeaf),
|
||||||
|
boolToInt(flags.Camera), boolToInt(flags.Screen),
|
||||||
|
int64(callID), int64(userID))
|
||||||
|
if err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndDMCall завершает звонок: не ответившие помечаются пропустившими, те, кто
|
||||||
|
// был в комнате, — вышедшими. Повторный вызов не меняет уже завершённый звонок.
|
||||||
|
func (s *Store) EndDMCall(ctx context.Context, callID uint64, endedBy *uint64, reason string) error {
|
||||||
|
now := s.Now()
|
||||||
|
var actor sql.NullInt64
|
||||||
|
if endedBy != nil {
|
||||||
|
actor = sql.NullInt64{Int64: int64(*endedBy), Valid: true}
|
||||||
|
}
|
||||||
|
return s.InTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE dm_calls SET status = ?, ended_at = ?, ended_by = ?, end_reason = ?
|
||||||
|
WHERE id = ? AND status <> ?`,
|
||||||
|
DMCallEnded, now, actor, reason, int64(callID), DMCallEnded); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE dm_call_participants SET state = ?, left_at = ?
|
||||||
|
WHERE call_id = ? AND state = ?`,
|
||||||
|
DMCallMissed, now, int64(callID), DMCallInvited); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE dm_call_participants SET state = ?, left_at = ?
|
||||||
|
WHERE call_id = ? AND state = ?`,
|
||||||
|
DMCallLeft, now, int64(callID), DMCallJoined)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDMCalls отдаёт историю звонков беседы от новых к старым. beforeID —
|
||||||
|
// курсор: отдаются звонки с меньшим идентификатором.
|
||||||
|
func (s *Store) ListDMCalls(ctx context.Context, channelID uint64, limit int, beforeID uint64) ([]DMCallWithParticipants, error) {
|
||||||
|
if limit <= 0 || limit > maxDMCallHistory {
|
||||||
|
limit = defaultDMCallHistory
|
||||||
|
}
|
||||||
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
|
SELECT `+dmCallColumns+` FROM dm_calls
|
||||||
|
WHERE channel_id = ? AND (? = 0 OR id < ?)
|
||||||
|
ORDER BY id DESC LIMIT ?`,
|
||||||
|
int64(channelID), int64(beforeID), int64(beforeID), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
calls := make([]DMCallWithParticipants, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
call, err := scanDMCall(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
calls = append(calls, DMCallWithParticipants{DMCall: *call})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(calls) == 0 {
|
||||||
|
return calls, nil
|
||||||
|
}
|
||||||
|
// Участники тянутся одним запросом на страницу: диапазон идентификаторов
|
||||||
|
// страницы непрерывен, поэтому лишних звонков в выборку не попадает.
|
||||||
|
page := make(map[uint64][]DMCallParticipant, len(calls))
|
||||||
|
participants, err := s.listDMCallParticipantsRange(ctx, channelID, calls[len(calls)-1].ID, beforeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, participant := range participants {
|
||||||
|
page[participant.CallID] = append(page[participant.CallID], participant)
|
||||||
|
}
|
||||||
|
for index := range calls {
|
||||||
|
calls[index].Participants = page[calls[index].ID]
|
||||||
|
if calls[index].Participants == nil {
|
||||||
|
calls[index].Participants = []DMCallParticipant{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return calls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStaleDMCalls ищет звонки для сторожа: гудки без ответа (ringingBefore) и
|
||||||
|
// разговоры, в которых не осталось никого (emptyBefore — время последнего
|
||||||
|
// обновления звонка). Оба условия — «строго раньше» указанного момента.
|
||||||
|
func (s *Store) ListStaleDMCalls(ctx context.Context, ringingBefore, emptyBefore time.Time) ([]DMCall, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
|
SELECT `+dmCallColumns+` FROM dm_calls c
|
||||||
|
WHERE c.status <> ?
|
||||||
|
AND (
|
||||||
|
(c.status = ? AND c.created_at < ?)
|
||||||
|
OR (c.status = ? AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM dm_call_participants p
|
||||||
|
WHERE p.call_id = c.id AND p.state = ?
|
||||||
|
) AND c.created_at < ?)
|
||||||
|
)
|
||||||
|
ORDER BY c.id`,
|
||||||
|
DMCallEnded, DMCallRinging, s.Timestamp(ringingBefore), DMCallActive, DMCallJoined,
|
||||||
|
s.Timestamp(emptyBefore))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
calls := make([]DMCall, 0, 4)
|
||||||
|
for rows.Next() {
|
||||||
|
call, err := scanDMCall(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
calls = append(calls, *call)
|
||||||
|
}
|
||||||
|
return calls, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountLiveDMCalls считает звонки, которые ещё не завершены (диагностика).
|
||||||
|
func (s *Store) CountLiveDMCalls(ctx context.Context) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM dm_calls WHERE status <> ?`, DMCallEnded).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDMCallRow возвращает звонок без состава.
|
||||||
|
func (s *Store) GetDMCallRow(ctx context.Context, callID uint64) (*DMCall, error) {
|
||||||
|
row := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT `+dmCallColumns+` FROM dm_calls WHERE id = ?`, int64(callID))
|
||||||
|
return scanDMCall(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listDMCallParticipants перечисляет состав звонка.
|
||||||
|
func (s *Store) listDMCallParticipants(ctx context.Context, callID uint64) ([]DMCallParticipant, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx,
|
||||||
|
`SELECT `+dmCallParticipantColumns+` FROM dm_call_participants WHERE call_id = ? ORDER BY invited_at, user_id`,
|
||||||
|
int64(callID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanDMCallParticipants(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listDMCallParticipantsRange перечисляет состав звонков страницы истории.
|
||||||
|
func (s *Store) listDMCallParticipantsRange(ctx context.Context, channelID, minCallID, beforeID uint64) ([]DMCallParticipant, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
|
SELECT p.call_id, p.user_id, p.state, p.invited_at, p.joined_at, p.left_at,
|
||||||
|
p.self_mute, p.self_deaf, p.camera, p.screen
|
||||||
|
FROM dm_call_participants p JOIN dm_calls c ON c.id = p.call_id
|
||||||
|
WHERE c.channel_id = ? AND c.id >= ? AND (? = 0 OR c.id < ?)
|
||||||
|
ORDER BY p.call_id DESC, p.invited_at, p.user_id`,
|
||||||
|
int64(channelID), int64(minCallID), int64(beforeID), int64(beforeID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanDMCallParticipants(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDMCall(scanner interface{ Scan(...any) error }) (*DMCall, error) {
|
||||||
|
var (
|
||||||
|
call DMCall
|
||||||
|
createdAt string
|
||||||
|
startedAt, endedAt sql.NullString
|
||||||
|
endedBy sql.NullInt64
|
||||||
|
endReason sql.NullString
|
||||||
|
)
|
||||||
|
err := scanner.Scan(&call.ID, &call.ChannelID, &call.InitiatorID, &call.Status, &call.Media,
|
||||||
|
&createdAt, &startedAt, &endedAt, &endedBy, &endReason)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
call.CreatedAt = parseTimestamp(createdAt)
|
||||||
|
if startedAt.Valid {
|
||||||
|
value := parseTimestamp(startedAt.String)
|
||||||
|
call.StartedAt = &value
|
||||||
|
}
|
||||||
|
if endedAt.Valid {
|
||||||
|
value := parseTimestamp(endedAt.String)
|
||||||
|
call.EndedAt = &value
|
||||||
|
}
|
||||||
|
call.EndedBy = optionalID(endedBy)
|
||||||
|
call.EndReason = endReason.String
|
||||||
|
return &call, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDMCallParticipants(rows *sql.Rows) ([]DMCallParticipant, error) {
|
||||||
|
participants := make([]DMCallParticipant, 0, 4)
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
participant DMCallParticipant
|
||||||
|
invitedAt string
|
||||||
|
joinedAt, leftAt sql.NullString
|
||||||
|
selfMute, selfDeaf int
|
||||||
|
camera, screen int
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&participant.CallID, &participant.UserID, &participant.State, &invitedAt,
|
||||||
|
&joinedAt, &leftAt, &selfMute, &selfDeaf, &camera, &screen); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
participant.InvitedAt = parseTimestamp(invitedAt)
|
||||||
|
if joinedAt.Valid {
|
||||||
|
value := parseTimestamp(joinedAt.String)
|
||||||
|
participant.JoinedAt = &value
|
||||||
|
}
|
||||||
|
if leftAt.Valid {
|
||||||
|
value := parseTimestamp(leftAt.String)
|
||||||
|
participant.LeftAt = &value
|
||||||
|
}
|
||||||
|
participant.SelfMute = selfMute == 1
|
||||||
|
participant.SelfDeaf = selfDeaf == 1
|
||||||
|
participant.Camera = camera == 1
|
||||||
|
participant.Screen = screen == 1
|
||||||
|
participants = append(participants, participant)
|
||||||
|
}
|
||||||
|
return participants, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"glchat/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newCallStore открывает тестовую БД с применёнными миграциями.
|
||||||
|
func newCallStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := database.Open(ctx, database.Options{
|
||||||
|
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||||
|
ReadPool: 2,
|
||||||
|
Migrate: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open test database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Errorf("close test database: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return New(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDMCallStoreLifecycle — состояние звонка в хранилище (AGENT.md 7.8):
|
||||||
|
// один звонок на беседу, переход в active на втором участнике, история.
|
||||||
|
func TestDMCallStoreLifecycle(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
st := newCallStore(t)
|
||||||
|
alice := createStoreUser(t, st, "alice_store")
|
||||||
|
bob := createStoreUser(t, st, "bob_store")
|
||||||
|
channel, err := st.CreateGroupDMChannel(ctx, alice.ID, "Беседа", []uint64{bob.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conversation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
call, err := st.CreateDMCall(ctx, channel.ID, alice.ID, "audio", []uint64{bob.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create call: %v", err)
|
||||||
|
}
|
||||||
|
if call.Status != DMCallRinging || call.StartedAt != nil {
|
||||||
|
t.Fatalf("new call = %+v", call.DMCall)
|
||||||
|
}
|
||||||
|
if len(call.Participants) != 2 {
|
||||||
|
t.Fatalf("participants = %+v", call.Participants)
|
||||||
|
}
|
||||||
|
// Второй одновременный звонок в беседе запрещён.
|
||||||
|
if _, err := st.CreateDMCall(ctx, channel.ID, bob.ID, "audio", nil); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Fatalf("second call error = %v, want ErrConflict", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined, err := st.JoinDMCallParticipant(ctx, call.ID, bob.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join: %v", err)
|
||||||
|
}
|
||||||
|
if joined.Status != DMCallActive || joined.StartedAt == nil {
|
||||||
|
t.Fatalf("call after second join = %+v", joined)
|
||||||
|
}
|
||||||
|
if err := st.UpdateDMCallFlags(ctx, call.ID, bob.ID, DMCallFlags{SelfMute: true, Camera: true}); err != nil {
|
||||||
|
t.Fatalf("update flags: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.LeaveDMCallParticipant(ctx, call.ID, alice.ID, DMCallLeft); err != nil {
|
||||||
|
t.Fatalf("leave: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.EndDMCall(ctx, call.ID, &bob.ID, DMCallReasonCompleted); err != nil {
|
||||||
|
t.Fatalf("end: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текущего звонка больше нет, а история хранит состав и длительность.
|
||||||
|
if _, err := st.GetLiveDMCall(ctx, channel.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("live call error = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
history, err := st.ListDMCalls(ctx, channel.ID, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("history = %+v", history)
|
||||||
|
}
|
||||||
|
entry := history[0]
|
||||||
|
if entry.Status != DMCallEnded || entry.EndReason != DMCallReasonCompleted || entry.EndedBy == nil {
|
||||||
|
t.Fatalf("history entry = %+v", entry.DMCall)
|
||||||
|
}
|
||||||
|
if st.DMCallDurationSeconds(&entry.DMCall) < 0 {
|
||||||
|
t.Fatalf("duration = %d", st.DMCallDurationSeconds(&entry.DMCall))
|
||||||
|
}
|
||||||
|
states := map[uint64]string{}
|
||||||
|
for _, participant := range entry.Participants {
|
||||||
|
states[participant.UserID] = participant.State
|
||||||
|
}
|
||||||
|
if states[alice.ID] != DMCallLeft || states[bob.ID] != DMCallLeft {
|
||||||
|
t.Fatalf("participant states = %+v", states)
|
||||||
|
}
|
||||||
|
|
||||||
|
// После завершения можно звонить снова: живой звонок только один.
|
||||||
|
next, err := st.CreateDMCall(ctx, channel.ID, bob.ID, "video", []uint64{alice.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create next call: %v", err)
|
||||||
|
}
|
||||||
|
if next.Status != DMCallRinging || next.InitiatorID != bob.ID {
|
||||||
|
t.Fatalf("next call = %+v", next.DMCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDMCallStaleSweep — сторож находит гудки без ответа и опустевшие комнаты.
|
||||||
|
func TestDMCallStaleSweep(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
st := newCallStore(t)
|
||||||
|
alice := createStoreUser(t, st, "alice_sweep")
|
||||||
|
bob := createStoreUser(t, st, "bob_sweep")
|
||||||
|
channel, err := st.CreateGroupDMChannel(ctx, alice.ID, "Беседа", []uint64{bob.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conversation: %v", err)
|
||||||
|
}
|
||||||
|
call, err := st.CreateDMCall(ctx, channel.ID, alice.ID, "audio", []uint64{bob.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create call: %v", err)
|
||||||
|
}
|
||||||
|
stale, err := st.ListStaleDMCalls(ctx, time.Now().UTC().Add(time.Minute), time.Now().UTC().Add(time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list stale: %v", err)
|
||||||
|
}
|
||||||
|
if len(stale) != 1 || stale[0].ID != call.ID {
|
||||||
|
t.Fatalf("stale calls = %+v", stale)
|
||||||
|
}
|
||||||
|
fresh, err := st.ListStaleDMCalls(ctx, time.Now().UTC().Add(-time.Minute), time.Now().UTC().Add(-time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list fresh: %v", err)
|
||||||
|
}
|
||||||
|
if len(fresh) != 0 {
|
||||||
|
t.Fatalf("fresh call must not be swept: %+v", fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createStoreUser(t *testing.T, st *Store, username string) *User {
|
||||||
|
t.Helper()
|
||||||
|
user, err := st.CreateUser(t.Context(), CreateUserParams{
|
||||||
|
Username: username,
|
||||||
|
DisplayName: username,
|
||||||
|
EmailEnc: "enc",
|
||||||
|
EmailIndex: username,
|
||||||
|
PasswordHash: "hash",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user %s: %v", username, err)
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
@@ -137,11 +137,13 @@ type DMParticipant struct {
|
|||||||
IsCurrentUser bool
|
IsCurrentUser bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// DMParticipantProfiles возвращает участников беседы с профилями.
|
// DMParticipantProfiles возвращает участников беседы с профилями. Выражение
|
||||||
|
// «владелец» обёрнуто в COALESCE: у 1:1 dm_owner_id пуст, а сравнение с NULL
|
||||||
|
// даёт NULL, который нельзя прочитать в int.
|
||||||
func (s *Store) DMParticipantProfiles(ctx context.Context, channelID, currentUserID uint64) ([]DMParticipant, error) {
|
func (s *Store) DMParticipantProfiles(ctx context.Context, channelID, currentUserID uint64) ([]DMParticipant, error) {
|
||||||
rows, err := s.reader.QueryContext(ctx, `
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
SELECT u.id, u.username, u.display_name, u.avatar_file_id, u.status, u.last_seen_at,
|
SELECT u.id, u.username, u.display_name, u.avatar_file_id, u.status, u.last_seen_at,
|
||||||
c.dm_owner_id = u.id
|
COALESCE(c.dm_owner_id = u.id, 0)
|
||||||
FROM dm_participants p
|
FROM dm_participants p
|
||||||
JOIN users u ON u.id = p.user_id
|
JOIN users u ON u.id = p.user_id
|
||||||
JOIN channels c ON c.id = p.channel_id
|
JOIN channels c ON c.id = p.channel_id
|
||||||
|
|||||||
Reference in New Issue
Block a user