feat: add backend, database, and protocol foundations

This commit is contained in:
DiheChen committed 2026-10-30 01:32:19 +08:00
commit ec08d487e1
179 files changed
+27079

No files matched your search

+100
View File
@@ -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()
);
+5
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
-- +goose Up
ALTER TABLE tracks DROP COLUMN kuwo_id;
+11
View File
@@ -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
);
+6
View File
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
-- +goose Up
-- 至多一行;没有这一行时品牌名是默认的 Alisten。
CREATE TABLE site (
id boolean PRIMARY KEY DEFAULT true CHECK (id),
brand_name text NOT NULL
);
+6
View File
@@ -0,0 +1,6 @@
package migrations
import "embed"
//go:embed *.sql
var FS embed.FS
+14
View File
@@ -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;
+12
View File
@@ -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;
+55
View File
@@ -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);
+11
View File
@@ -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;
+14
View File
@@ -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 *;
+24
View File
@@ -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);
+6
View File
@@ -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;
+40
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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();