feat: add backend, database, and protocol foundations
This commit is contained in:
commit
ec08d487e1
179 files changed
+27079
No files matched your search
@@ -0,0 +1,100 @@
|
||||
-- +goose Up
|
||||
-- 可选的文本值在没有或不适用时为 NULL;空串只表示值本身为空,例如被清空的房间描述。
|
||||
CREATE TABLE members (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
avatar_hash text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE rooms (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
kind text NOT NULL CHECK (kind IN ('persistent', 'ephemeral')),
|
||||
slug text UNIQUE CHECK (slug IS NULL OR kind = 'persistent'),
|
||||
name text NOT NULL,
|
||||
description text NOT NULL,
|
||||
password_hash text,
|
||||
-- 接管房间时加 1;落盘以它为条件,因此过期的 owner 无法写入。
|
||||
term bigint NOT NULL DEFAULT 0,
|
||||
play_mode text NOT NULL DEFAULT 'sequential' CHECK (play_mode IN ('sequential', 'random')),
|
||||
entry_seq bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE room_entries (
|
||||
room_id uuid NOT NULL REFERENCES rooms ON DELETE CASCADE,
|
||||
entry_id bigint NOT NULL,
|
||||
slot text NOT NULL CHECK (slot IN ('waiting', 'pending', 'current')),
|
||||
source text NOT NULL,
|
||||
track_id text NOT NULL,
|
||||
title text NOT NULL,
|
||||
artist text,
|
||||
album text,
|
||||
cover_url text,
|
||||
cover_key text,
|
||||
meta_duration_ms bigint NOT NULL,
|
||||
-- AutoDJ 加入的条目没有点歌的成员,by_* 都为 NULL。
|
||||
by_member_id uuid,
|
||||
by_name text,
|
||||
by_avatar_hash text,
|
||||
auto boolean NOT NULL,
|
||||
likes integer NOT NULL,
|
||||
added_at timestamptz NOT NULL,
|
||||
started_at timestamptz CHECK ((slot = 'current') = (started_at IS NOT NULL)),
|
||||
PRIMARY KEY (room_id, entry_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ON room_entries (source, track_id);
|
||||
|
||||
CREATE TABLE room_tickets (
|
||||
token_hash bytea PRIMARY KEY,
|
||||
room_id uuid NOT NULL REFERENCES rooms ON DELETE CASCADE,
|
||||
member_id uuid NOT NULL REFERENCES members,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ON room_tickets (room_id);
|
||||
|
||||
-- 只保存准备成功的结果。
|
||||
CREATE TABLE tracks (
|
||||
source text NOT NULL,
|
||||
track_id text NOT NULL,
|
||||
title text NOT NULL,
|
||||
artist text,
|
||||
album text,
|
||||
cover_url text,
|
||||
cover_key text,
|
||||
duration_ms bigint NOT NULL,
|
||||
gain double precision NOT NULL,
|
||||
lyric text,
|
||||
kuwo_id text NOT NULL,
|
||||
media_key text,
|
||||
media_size bigint,
|
||||
prepared_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- 临时房间的播放记录随房间删除,所以不从 plays 计算。
|
||||
last_played_at timestamptz,
|
||||
PRIMARY KEY (source, track_id)
|
||||
);
|
||||
|
||||
CREATE TABLE plays (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
room_id uuid NOT NULL REFERENCES rooms ON DELETE CASCADE,
|
||||
source text NOT NULL,
|
||||
track_id text NOT NULL,
|
||||
by_member_id uuid,
|
||||
by_name text,
|
||||
by_avatar_hash text,
|
||||
auto boolean NOT NULL,
|
||||
started_at timestamptz NOT NULL,
|
||||
ended_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ON plays (room_id, started_at);
|
||||
|
||||
-- 只有已设置的凭据才有对应的行。
|
||||
CREATE TABLE credentials (
|
||||
name text PRIMARY KEY,
|
||||
value text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE room_entries ADD COLUMN page_url text NOT NULL DEFAULT '';
|
||||
ALTER TABLE room_entries ALTER COLUMN page_url DROP DEFAULT;
|
||||
ALTER TABLE tracks ADD COLUMN page_url text NOT NULL DEFAULT '';
|
||||
ALTER TABLE tracks ALTER COLUMN page_url DROP DEFAULT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE tracks DROP COLUMN kuwo_id;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- 第一次生成 API Key 时创建,此后重置 Key、修改密码都不换。
|
||||
ALTER TABLE rooms
|
||||
ADD COLUMN bot_member_id uuid REFERENCES members,
|
||||
ADD CHECK (bot_member_id IS NULL OR kind = 'persistent');
|
||||
|
||||
-- 凭证只存哈希的唯一例外:API Key 要能随时给进过房间的成员和管理员查看。Key 就是 bot 成员的票据,票据作废时明文随之删除。
|
||||
CREATE TABLE room_api_keys (
|
||||
token_hash bytea PRIMARY KEY REFERENCES room_tickets ON DELETE CASCADE,
|
||||
key text NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
-- 条目的轮次在入队时定下,轮流播放时点赞数相同的歌按它排列;已有的条目都算第 0 轮。
|
||||
ALTER TABLE room_entries ADD COLUMN round bigint NOT NULL DEFAULT 0;
|
||||
ALTER TABLE rooms
|
||||
DROP CONSTRAINT rooms_play_mode_check,
|
||||
ADD CONSTRAINT rooms_play_mode_check CHECK (play_mode IN ('sequential', 'random', 'rotation'));
|
||||
@@ -0,0 +1,4 @@
|
||||
-- +goose Up
|
||||
-- 已有的 Key 都给进过房间的成员看过,一起作废;bot 成员留给重新生成的 Key。
|
||||
DROP TABLE room_api_keys;
|
||||
DELETE FROM room_tickets t USING rooms r WHERE t.room_id = r.id AND t.member_id = r.bot_member_id;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
-- 至多一行;没有这一行时品牌名是默认的 Alisten。
|
||||
CREATE TABLE site (
|
||||
id boolean PRIMARY KEY DEFAULT true CHECK (id),
|
||||
brand_name text NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
@@ -0,0 +1,14 @@
|
||||
-- name: LockRoomBot :one
|
||||
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个房间的重置要依次进行,
|
||||
-- 同时到达的首次生成才只会创建一个 bot 成员,最后只留下一把 Key。
|
||||
SELECT bot_member_id FROM rooms WHERE id = @id FOR NO KEY UPDATE;
|
||||
|
||||
-- name: SetRoomBot :exec
|
||||
UPDATE rooms SET bot_member_id = @bot_member_id::uuid WHERE id = @id;
|
||||
|
||||
-- name: BotTicketCreatedAt :one
|
||||
SELECT t.created_at FROM room_tickets t JOIN rooms r ON r.id = t.room_id AND r.bot_member_id = t.member_id
|
||||
WHERE t.room_id = @room_id;
|
||||
|
||||
-- name: DeleteMemberTickets :exec
|
||||
DELETE FROM room_tickets WHERE room_id = @room_id AND member_id = @member_id;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- name: Credential :one
|
||||
SELECT value FROM credentials WHERE name = @name;
|
||||
|
||||
-- name: CredentialWithUpdatedAt :one
|
||||
SELECT value, updated_at FROM credentials WHERE name = @name;
|
||||
|
||||
-- name: SetCredential :exec
|
||||
INSERT INTO credentials (name, value) VALUES (@name, @value)
|
||||
ON CONFLICT (name) DO UPDATE SET value = excluded.value, updated_at = now();
|
||||
|
||||
-- name: DeleteCredential :exec
|
||||
DELETE FROM credentials WHERE name = @name;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- name: AutoDJSeeds :many
|
||||
-- 只看最近 1000 次播放:按 plays (room_id, started_at) 的索引倒序读到 1000 行就停,耗时与历史的长度无关。
|
||||
SELECT track_id FROM (
|
||||
SELECT DISTINCT ON (track_id) track_id, tier, rank
|
||||
FROM (
|
||||
-- 队列中的歌按等待队列的排序规则排列:轮流播放时点赞数相同的先比轮次。
|
||||
SELECT e.track_id, 0 AS tier,
|
||||
row_number() OVER (ORDER BY CASE e.slot WHEN 'current' THEN 0 WHEN 'pending' THEN 1 ELSE 2 END, e.likes DESC,
|
||||
CASE WHEN r.play_mode = 'rotation' THEN e.round END, e.entry_id) AS rank
|
||||
FROM room_entries e
|
||||
JOIN rooms r ON r.id = e.room_id
|
||||
WHERE e.room_id = @room_id AND e.source = 'netease' AND NOT e.auto
|
||||
AND (e.slot = 'current' OR @include_queue::boolean)
|
||||
UNION ALL
|
||||
SELECT p.track_id, 1, row_number() OVER (ORDER BY max(p.started_at) DESC)
|
||||
FROM (
|
||||
SELECT track_id, source, auto, started_at FROM plays
|
||||
WHERE room_id = @room_id
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1000
|
||||
) p
|
||||
WHERE p.source = 'netease' AND NOT p.auto
|
||||
GROUP BY p.track_id
|
||||
) candidates
|
||||
ORDER BY track_id, tier, rank
|
||||
) seeds
|
||||
ORDER BY tier, rank
|
||||
LIMIT 32;
|
||||
|
||||
-- name: AutoDJExclusions :many
|
||||
SELECT e.source, e.track_id FROM room_entries e
|
||||
WHERE e.room_id = @room_id AND e.slot IN ('pending', 'waiting')
|
||||
UNION
|
||||
SELECT source, track_id FROM (
|
||||
SELECT source, track_id
|
||||
FROM (
|
||||
SELECT c.source, c.track_id, c.started_at FROM room_entries c
|
||||
WHERE c.room_id = @room_id AND c.slot = 'current' AND c.auto
|
||||
UNION ALL
|
||||
SELECT p.source, p.track_id, p.started_at FROM (
|
||||
SELECT source, track_id, auto, started_at FROM plays
|
||||
WHERE room_id = @room_id
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1000
|
||||
) p
|
||||
WHERE p.auto
|
||||
) auto_picks
|
||||
GROUP BY source, track_id
|
||||
ORDER BY max(started_at) DESC
|
||||
LIMIT 128
|
||||
) recent
|
||||
ORDER BY source, track_id;
|
||||
|
||||
-- name: TrackInUse :one
|
||||
SELECT EXISTS (SELECT 1 FROM room_entries WHERE source = @source AND track_id = @track_id);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- name: ListStoredMedia :many
|
||||
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
|
||||
WHERE media_key IS NOT NULL
|
||||
ORDER BY prepared_at DESC, track_id;
|
||||
|
||||
-- name: StoredMedia :one
|
||||
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
|
||||
WHERE media_key = @media_key::text;
|
||||
|
||||
-- name: DeleteStoredMedia :exec
|
||||
DELETE FROM tracks WHERE media_key = @media_key::text;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- name: CreateMember :one
|
||||
INSERT INTO members (token_hash, name) VALUES (@token_hash, @name)
|
||||
RETURNING *;
|
||||
|
||||
-- name: MemberByToken :one
|
||||
SELECT * FROM members WHERE token_hash = @token_hash;
|
||||
|
||||
-- name: UpdateMember :one
|
||||
-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。
|
||||
UPDATE members
|
||||
SET name = coalesce(sqlc.narg(name), name),
|
||||
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END
|
||||
WHERE id = @id
|
||||
RETURNING *;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- name: CreateRoom :one
|
||||
INSERT INTO rooms (kind, slug, name, description, password_hash)
|
||||
VALUES (@kind, NULLIF(@slug::text, ''), @name, @description, NULLIF(@password_hash::text, ''))
|
||||
RETURNING id;
|
||||
|
||||
-- name: ListRooms :many
|
||||
SELECT * FROM rooms ORDER BY id;
|
||||
|
||||
-- name: RoomByID :one
|
||||
SELECT * FROM rooms WHERE id = @id;
|
||||
|
||||
-- name: RoomBySlug :one
|
||||
SELECT * FROM rooms WHERE slug = @slug::text;
|
||||
|
||||
-- name: SetRoomSlug :execrows
|
||||
UPDATE rooms SET slug = NULLIF(@slug::text, '') WHERE id = @id;
|
||||
|
||||
-- name: SetRoomPassword :execrows
|
||||
UPDATE rooms SET password_hash = NULLIF(@password_hash::text, '') WHERE id = @id;
|
||||
|
||||
-- name: DeleteRoomTicketsExceptBot :exec
|
||||
-- 没有 bot 成员的房间 bot_member_id 为 NULL,写成 <> 就一张票据都不删。
|
||||
DELETE FROM room_tickets
|
||||
WHERE room_id = @room_id AND member_id IS DISTINCT FROM (SELECT bot_member_id FROM rooms WHERE id = @room_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- name: BrandName :one
|
||||
SELECT brand_name FROM site;
|
||||
|
||||
-- name: SetBrandName :exec
|
||||
INSERT INTO site (brand_name) VALUES (@brand_name)
|
||||
ON CONFLICT (id) DO UPDATE SET brand_name = excluded.brand_name;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- name: TakeRoom :one
|
||||
UPDATE rooms SET term = term + 1 WHERE id = @id RETURNING *;
|
||||
|
||||
-- name: RoomEntries :many
|
||||
SELECT e.entry_id, e.slot, e.source, e.track_id, e.title, e.artist, e.album, e.cover_url, e.cover_key, e.page_url, e.meta_duration_ms,
|
||||
e.by_member_id, e.by_name, e.by_avatar_hash, e.auto, e.likes, e.round, e.added_at, e.started_at,
|
||||
t.title AS track_title, t.artist AS track_artist, t.album AS track_album, t.cover_url AS track_cover_url,
|
||||
t.cover_key AS track_cover_key, t.page_url AS track_page_url, t.duration_ms, t.gain, t.lyric
|
||||
FROM room_entries e
|
||||
LEFT JOIN tracks t ON t.source = e.source AND t.track_id = e.track_id
|
||||
WHERE e.room_id = @room_id
|
||||
ORDER BY e.entry_id;
|
||||
|
||||
-- name: SaveRoomState :execrows
|
||||
UPDATE rooms SET name = @name, description = @description, play_mode = @play_mode, entry_seq = @entry_seq
|
||||
WHERE id = @id AND term = @term;
|
||||
|
||||
-- name: DeleteRoomEntries :exec
|
||||
DELETE FROM room_entries WHERE room_id = @room_id;
|
||||
|
||||
-- name: InsertRoomEntries :copyfrom
|
||||
INSERT INTO room_entries (
|
||||
room_id, entry_id, slot, source, track_id, title, artist, album, cover_url, cover_key, page_url, meta_duration_ms,
|
||||
by_member_id, by_name, by_avatar_hash, auto, likes, round, added_at, started_at
|
||||
) VALUES (
|
||||
@room_id, @entry_id, @slot, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @page_url, @meta_duration_ms,
|
||||
@by_member_id, @by_name, @by_avatar_hash, @auto, @likes, @round, @added_at, @started_at
|
||||
);
|
||||
|
||||
-- name: InsertPlays :copyfrom
|
||||
INSERT INTO plays (room_id, source, track_id, by_member_id, by_name, by_avatar_hash, auto, started_at, ended_at)
|
||||
VALUES (@room_id, @source, @track_id, @by_member_id, @by_name, @by_avatar_hash, @auto, @started_at, @ended_at);
|
||||
|
||||
-- name: MarkPlayed :exec
|
||||
-- 不同房间同时落盘时,先开播的那一次可能后写入。
|
||||
UPDATE tracks SET last_played_at = GREATEST(last_played_at, @started_at::timestamptz)
|
||||
WHERE source = @source AND track_id = @track_id;
|
||||
|
||||
-- name: DeleteRoom :execrows
|
||||
DELETE FROM rooms WHERE id = @id AND term = @term;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- name: LockRoomPassword :one
|
||||
-- FOR SHARE 让修改密码和删除房间等到这次签发提交之后,它们随后删除票据时会删掉这一张。
|
||||
SELECT coalesce(password_hash, '')::text AS password_hash FROM rooms WHERE id = @id FOR SHARE;
|
||||
|
||||
-- name: CreateTicket :exec
|
||||
INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES (@token_hash, @room_id, @member_id);
|
||||
|
||||
-- name: TicketHolder :one
|
||||
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot
|
||||
FROM room_tickets t
|
||||
JOIN members m ON m.id = t.member_id
|
||||
JOIN rooms r ON r.id = t.room_id
|
||||
WHERE t.token_hash = @token_hash;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- name: PreparedTrack :one
|
||||
SELECT title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size FROM tracks
|
||||
WHERE source = @source AND track_id = @track_id;
|
||||
|
||||
-- name: SavePreparedTrack :exec
|
||||
-- 准备任务只对未完成的任务去重,同一首歌可能先后准备两次,以后一次的结果为准。
|
||||
INSERT INTO tracks (source, track_id, title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size)
|
||||
VALUES (@source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @page_url, @duration_ms, @gain, @lyric, @media_key, @media_size)
|
||||
ON CONFLICT (source, track_id) DO UPDATE SET
|
||||
title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
|
||||
cover_key = excluded.cover_key, page_url = excluded.page_url, duration_ms = excluded.duration_ms, gain = excluded.gain,
|
||||
lyric = excluded.lyric, media_key = excluded.media_key, media_size = excluded.media_size,
|
||||
prepared_at = now();
|
||||
Reference in new issue
Block a user