Compare commits

..
18 Commits
Author SHA1 Message Date
DiheChen e539c7d78b feat: select several playlist songs to remove, save or reorder them and drag songs into place
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-09 21:33:06 +08:00
DiheChen d73a161e84 feat: browse my playlists as a sortable list or a glowing cover grid and open each one 2026-11-09 21:32:21 +08:00
DiheChen ffd2b390f0 feat: save songs to my playlists from the player, queue and history 2026-11-09 21:31:18 +08:00
DiheChen ef4558bd65 feat: track member playlists in the web client 2026-11-09 21:30:19 +08:00
DiheChen c87b48f014 feat: tell which of my playlists contain a song 2026-11-09 21:29:43 +08:00
DiheChen 4a59c639cd feat: remove, copy and reorder several playlist songs at once 2026-11-09 21:28:12 +08:00
DiheChen a07ef81e3c feat: expose member playlists over HTTP 2026-11-09 21:27:41 +08:00
DiheChen ca6603a994 feat: manage member playlists with server-side track metadata 2026-11-09 21:26:41 +08:00
DiheChen 224ea02725 feat: store playlists with a fixed favorites list for every account 2026-11-09 21:25:18 +08:00
DiheChen f4e4d23831 feat: bind room tickets to member sessions and disconnect revoked sessions 2026-11-09 21:24:32 +08:00
DiheChen 83afee3142 feat: register and sign in from the settings page 2026-11-09 21:23:23 +08:00
DiheChen ec62be2d8d feat: switch registration and reset account passwords in the admin page 2026-11-09 21:22:40 +08:00
DiheChen 872904975d feat: expose account registration, login and admin recovery over HTTP 2026-11-09 21:21:23 +08:00
DiheChen b50e645d30 feat: register, log in and manage member accounts 2026-11-09 21:20:12 +08:00
DiheChen 7f67df3d41 feat: store whether registration is open 2026-11-09 21:19:37 +08:00
DiheChen a61df45c32 feat: store accounts and per-device sessions for members 2026-11-09 21:18:26 +08:00
DiheChen 90a3b26c77 feat: reject emails that are not shaped like name@example.com when updating a profile 2026-11-09 21:17:42 +08:00
DiheChen 729e5861f5 feat: take the client address from trusted proxies 2026-11-09 21:16:26 +08:00
455 changed files with 3034 additions and 22018 deletions

No files matched your search

+1
View File
@@ -2,6 +2,7 @@
ALISTEN_HOST=127.0.0.1 # 绑定的 ip
ALISTEN_PORT=9876 # 监听的端口
ALISTEN_ALLOWED_ORIGINS="" # 逗号分隔的 Origin 允许列表,留空时仅允许 ALISTEN_PUBLIC_URL
ALISTEN_TRUSTED_PROXIES="" # 逗号分隔的 IP 地址或 CIDR,来自这些地址的反向代理传来的 X-Forwarded-For 才被采信;留空时,compose.prod.yaml 信任 proxy 网络 172.31.250.0/24,其他情况按连接的对端地址限流
ALISTEN_LOG_LEVEL=info # 日志等级, 可选值为 debug、info、warn、error
ALISTEN_WORKER_TMP_SIZE="1g" # Worker 临时文件大小
ALISTEN_ADMIN_TOKEN="" # 进入管理后台需要的凭证, 留空则禁用后台
+7 -7
View File
@@ -7,7 +7,7 @@ linters:
- forbidigo
exclusions:
rules:
# 这两个文件里未检查的错误不影响结果。decode.go 跳过 fmt 块的对齐填充失败时,读下一个块头会报错。loudness_bench_test.go 的 binary.Write 写入的是 bytes.Buffer,不会失败。
# 这两个文件中未检查的错误不影响结果:decode.go 跳过 fmt 块的对齐填充失败时,读取下一个块头会报错;loudness_bench_test.go 的 binary.Write 写入 bytes.Buffer,不会失败。
- path: internal/lib/wav/decode\.go|internal/lib/loudness/loudness_bench_test\.go
linters:
- errcheck
@@ -17,8 +17,8 @@ linters:
- forbidigo
settings:
depguard:
# 不以 $ 结尾的条目按前缀匹配。depguard 只拿排序后紧挨在导入路径之前的那一项比较,所以已经有前缀的子包不要再单列。
# 比如 deny 里同时有 os 和 os/exec 时,os/signal 就不再被禁止。
# 不以 $ 结尾的条目按前缀匹配。depguard 只拿排序后紧挨在导入路径之前的一项比较,已有前缀的子包不要再单列:
# deny 中同时有 os 和 os/exec 时,os/signal 就不再被禁止。
rules:
domain:
list-mode: strict
@@ -77,9 +77,9 @@ linters:
- github.com/dihemoe/alisten/internal/domain
- github.com/dihemoe/alisten/internal/lib
- github.com/dihemoe/alisten/internal/app
# 用于房间密码和账号密码的 argon2id 哈希。argon2 是纯计算,不做 I/O。
# 用于房间密码的 argon2id 哈希;纯计算,不做 I/O。
- golang.org/x/crypto/argon2
# 用于合并同一首歌的并发播放地址解析。singleflight 只协调 goroutine,不做 I/O。
# 用于合并同一首歌的并发播放地址解析;只协调 goroutine,不做 I/O。
- golang.org/x/sync/singleflight
deny:
- pkg: os
@@ -138,7 +138,7 @@ linters:
desc: lib 与业务无关,不能依赖 bootstrap
- pkg: github.com/dihemoe/alisten/protocol
desc: protocol 只能被 internal/adapter/in 使用
# app 通过 logctx 记录日志的关联字段。logctx 只依赖标准库,这样 app 才不会经由 logctx 依赖 OpenTelemetry。
# app 通过 logctx 记录日志的关联字段;logctx 只依赖标准库,app 才不会经由它依赖 OpenTelemetry。
logctx:
list-mode: strict
files:
@@ -157,7 +157,7 @@ linters:
list-mode: lax
files:
- "**/internal/adapter/out/**"
# sqlc 把查询代码生成在 postgres 适配器的 internal 目录下。用 lax 模式,是为了让这个更长的 allow 前缀优先于 deny。
# sqlc 把查询代码生成在 postgres 适配器的 internal 目录下;lax 模式让这个更长的 allow 前缀优先于 deny。
allow:
- github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query
deny:
+12
View File
@@ -39,6 +39,18 @@ vim .env
# 然后使用原来的部署命令重新启动
```
`proxy` 网络已经存在时,docker compose 不会按 `compose.prod.yaml` 修改它的网段。如果下面的命令输出的不是 `172.31.250.0/24`(比如网络由没有固定网段的旧版创建),serve 就不会信任 traefik 转发的客户端地址,所有客户端会被算进同一个限流额度。这时先停止服务、删除这个网络,再用原来的部署命令启动:
```bash
docker network inspect proxy --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
docker compose \
-f compose.yaml \
-f compose.prod.yaml \
down
# down 已经删掉这个网络时,下面的命令会提示网络不存在,可以忽略
docker network rm proxy
```
修改源代码后,自行构建和启动:
```bash
+8 -8
View File
@@ -150,7 +150,7 @@ func parseIDL(data []byte) (*idl, error) {
return &d, nil
}
// encoding/json 对重复的键只保留最后一个值,还会把显式的 null 解码为 nil。之后的检查和生成都假定节点存在,所以解码之前先拒绝重复的键和显式的 null。
// encoding/json 对重复的键只保留最后一个值,并把显式的 null 解码为 nil,而之后的检查和生成都假定节点存在,所以解码之前先拒绝这两种写法。
// encoding/json 匹配结构体字段时不区分大小写,会把 "MAXLENGTH" 当作 maxLength,所以关键字只接受规范的写法。
func checkJSON(dec *json.Decoder, where string, named bool) error {
tok, err := dec.Token()
@@ -193,7 +193,7 @@ func checkJSON(dec *json.Decoder, where string, named bool) error {
return nil
}
// 列表中字段的值都是对象,对象的键由 IDL 作者命名(类型名、属性名、错误码)。新增这类字段时要加入这个列表,否则与关键字只有大小写不同的名字会被拒绝。
// 值为对象、键由 IDL 作者命名的字段(类型名、属性名、状态码)。新增这类字段时要加入这个列表,否则与关键字只有大小写不同的名字会被拒绝。
var nameKeyed = []string{"$defs", "properties", "x-httpStatus"}
var idlKeywords = jsonNames(idl{}, wsSection{}, message{}, event{}, endpoint{}, httpBinding{}, closeCode{}, schema{})
@@ -255,7 +255,7 @@ func pathParams(path string) []string {
return params
}
// 在生成的 TypeScript client 中,路径参数和列表中的名字是同一个函数里的标识符,所以不能重名。
// 生成的 TypeScript client 中,路径参数与这些名字是同一个函数中的标识符,不能重名。
var tsClientNames = []string{"body", "send", "encodeURIComponent"}
var tsFixedNames = []string{
@@ -465,7 +465,7 @@ func (d *idl) checkField(where string, s *schema, required, inbound bool) error
}
}
// 请求中的数组元素只能引用对象或枚举类型,因为解码函数按类型名生成。
// 请求中的数组元素只能引用对象或枚举类型,解码函数按类型名生成。
func (d *idl) checkInboundArray(where string, s *schema) error {
if s.Items.Ref == "" || d.Defs[refName(s.Items.Ref)] != nil && d.Defs[refName(s.Items.Ref)].scalar() {
return fmt.Errorf("%s:请求中的数组元素只能引用对象或枚举类型", where)
@@ -561,7 +561,7 @@ func (d *idl) resolve(where, ref string) (*schema, error) {
return d.Defs[name], nil
}
// 分支的 properties 里不在 required 中的字段,是各分支共用的可选字段。每个分支都要列出同样的一组可选字段,解码时它们不参与匹配分支。
// 分支的 properties 中不在 required 里的字段是各分支共用的可选字段:每个分支都要列出同样的一组,解码时不参与匹配分支。
func checkAlternatives(where string, s *schema) error {
if len(s.OneOf) < 2 {
return fmt.Errorf("%s:oneOf 至少要有两个分支", where)
@@ -723,7 +723,7 @@ type endpointNames struct {
}
func (n endpointNames) addRoute(where string, b httpBinding, owner string) error {
// 路径参数的名字不影响路由。两条路由如果只有参数名不同,ServeMux 登记时会 panic,OpenAPI 也把它们视为同一个 path。
// 路径参数的名字不影响路由:ServeMux 登记两条这样的路由会 panic,OpenAPI 也把它们视为同一个 path。
shape := pathParamPattern.ReplaceAllString(b.Path, "{}")
route := b.Method + " " + shape
if first, ok := n.routes[route]; ok {
@@ -772,7 +772,7 @@ func (d *idl) checkRoomRoute(where string, m *message, names endpointNames) erro
}
if b.Method == "GET" || b.Method == "DELETE" {
for _, name := range slices.Sorted(maps.Keys(m.Request.Properties)) {
if t := d.jsonType(m.Request.Properties[name]); t != "string" && t != "integer" {
if d.jsonType(m.Request.Properties[name]) == "object" {
return fmt.Errorf("%s:%s 请求的字段 %s 来自路径或查询串,应是字符串、整数或枚举", where, b.Method, name)
}
}
@@ -780,7 +780,7 @@ func (d *idl) checkRoomRoute(where string, m *message, names endpointNames) erro
return names.addRoute(where, b, "房间内请求 "+m.Type+" 的路由")
}
// 请求的字段已经检查过,不会是 null、布尔或数字,所以只有 string、integer、object 和 array 四种。
// 请求的字段已经检查过,不会是数组、布尔或 null,所以只有 string、integer 和 object 三种。
func (d *idl) jsonType(s *schema) string {
if s.Ref != "" {
return d.Defs[refName(s.Ref)].Type
-5
View File
@@ -246,11 +246,6 @@ func TestParseIDLRejects(t *testing.T) {
defs(d)["Track"] = object(map[string]any{"source": ref("Kind"), "id": str()}, "source", "id")
d["x-requests"] = []any{roomBinding("GET", "/api/room", map[string]any{"track": ref("Track")}, "track")}
}, "x-requests[0].http:GET 请求的字段 track 来自路径或查询串,应是字符串、整数或枚举"},
{"DELETE 请求有数组字段", func(d map[string]any) {
withKind(d)
kinds := map[string]any{"type": "array", "items": ref("Kind"), "maxItems": 10}
d["x-requests"] = []any{roomBinding("DELETE", "/api/room/queue", map[string]any{"kinds": kinds}, "kinds")}
}, "x-requests[0].http:DELETE 请求的字段 kinds 来自路径或查询串,应是字符串、整数或枚举"},
{"204 的房间内请求有响应", func(d map[string]any) {
m := roomBinding("POST", "/api/room/messages", map[string]any{})
m["http"].(map[string]any)["status"] = 204
+2 -3
View File
@@ -75,9 +75,8 @@ func generateFrom(path string) ([]output, error) {
return generate(d)
}
// 生成函数只处理 check 接受过的 IDL,所以直接依赖 check 保证的结构,不再重复检查。check 保证了以下几点:
// 引用都指向存在的类型(标量类型已经展开),数组都有 items,anyOf 是 [类型, {"type": "null"}],
// allOf 的每个条件只按必填的枚举字段判断,需要解码函数的类型都已标记在 inbound 中。
// 生成函数只处理 check 接受过的 IDL,直接依赖它保证的结构,不再检查:引用都指向存在的类型(标量类型已经展开),数组都有 items,
// anyOf 是 [类型, {"type": "null"}],allOf 的每个条件只按必填的枚举字段判断,需要解码函数的类型都已标记在 inbound 中。
func generate(d *idl) ([]output, error) {
types, err := goTypes(d)
if err != nil {
+1 -1
View File
@@ -120,7 +120,7 @@ func openAPIResponses(b httpBinding, s *schema) map[string]any {
}
}
// 路径参数不放进查询串和请求体。204 的请求没有响应体。
// 路径参数不在查询串和请求体中;204 的请求没有响应体。
func roomOperation(schemas map[string]any, m *message) (map[string]any, error) {
b := *m.HTTP
response := m.Response
+7
View File
@@ -53,6 +53,7 @@ services:
environment:
ALISTEN_PUBLIC_URL: https://${ALISTEN_DOMAIN}
STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL}
ALISTEN_TRUSTED_PROXIES: ${ALISTEN_TRUSTED_PROXIES:-172.31.250.0/24}
networks:
- backend
- db
@@ -115,6 +116,12 @@ services:
networks:
proxy:
name: proxy
# traefik 经这个网络转发请求,serve 默认信任这个网段传来的 X-Forwarded-For。与已有的 docker 网络冲突时改这里,并同步修改 ALISTEN_TRUSTED_PROXIES。
# 网络已经存在时 docker compose 不会按这里改它的网段:proxy 网络不是这个网段时(比如由没有固定网段的旧版创建),
# 先 down 停止服务,再 docker network rm proxy 删除这个网络,然后启动。
ipam:
config:
- subnet: 172.31.250.0/24
backend:
name: alisten-backend
db:
+1
View File
@@ -40,6 +40,7 @@ services:
<<: *alisten-env
ALISTEN_PUBLIC_URL: ${ALISTEN_PUBLIC_URL}
ALISTEN_ALLOWED_ORIGINS: ${ALISTEN_ALLOWED_ORIGINS}
ALISTEN_TRUSTED_PROXIES: ${ALISTEN_TRUSTED_PROXIES:-}
ALISTEN_ADMIN_TOKEN: ${ALISTEN_ADMIN_TOKEN}
STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL}
GOMAXPROCS: "2"
+5 -5
View File
@@ -1,5 +1,5 @@
-- +goose Up
-- 可选的文本值没有或不适用时为 NULL。空串只表示值本身为空,例如被清空的房间描述。
-- 可选的文本值在没有或不适用时为 NULL;空串只表示值本身为空,例如被清空的房间描述。
CREATE TABLE members (
id uuid PRIMARY KEY DEFAULT uuidv7(),
token_hash bytea NOT NULL UNIQUE,
@@ -15,12 +15,12 @@ CREATE TABLE rooms (
name text NOT NULL,
description text NOT NULL,
password_hash text,
-- 每次接管房间时加 1。落盘以 term 为条件,所以过期的 owner 无法写入。
-- 接管房间时加 1;落盘以它为条件,因此过期的 owner 无法写入。
term bigint NOT NULL DEFAULT 0,
play_mode text NOT NULL DEFAULT 'sequential' CHECK (play_mode IN ('sequential', 'random', 'rotation')),
entry_seq bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
-- bot 成员在第一次生成 API Key 时创建,之后重置 Key、修改密码都不会更换。
-- 第一次生成 API Key 时创建,此后重置 Key、修改密码都不换。
bot_member_id uuid REFERENCES members,
CHECK (bot_member_id IS NULL OR kind = 'persistent')
);
@@ -46,7 +46,7 @@ CREATE TABLE room_entries (
added_at timestamptz NOT NULL,
started_at timestamptz CHECK ((slot = 'current') = (started_at IS NOT NULL)),
page_url text NOT NULL,
-- 条目的轮次在入队时定下。轮流播放时,点赞数相同的歌按轮次排列。
-- 条目的轮次在入队时定下,轮流播放时点赞数相同的歌按它排列。
round bigint NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, entry_id)
);
@@ -105,7 +105,7 @@ CREATE TABLE credentials (
updated_at timestamptz NOT NULL DEFAULT now()
);
-- site 表至多一行。没有这一行时,品牌名是默认的 Alisten。
-- 至多一行;没有这一行时品牌名是默认的 Alisten。
CREATE TABLE site (
id boolean PRIMARY KEY DEFAULT true CHECK (id),
brand_name text NOT NULL
+4 -4
View File
@@ -16,15 +16,15 @@ WHERE NOT EXISTS (SELECT FROM rooms r WHERE r.bot_member_id = m.id);
ALTER TABLE members DROP COLUMN token_hash;
-- 设置了用户名的成员是已注册的账号,否则是游客。用户名只能由小写字母、数字、下划线和连字符组成,所以按原文唯一即可。
-- 邮箱属于账号,在所有设备上一致。游客没有账号,不替匿名身份保存个人信息,所以游客的邮箱只存在游客自己的浏览器里。
-- 邮箱属于账号,在所有设备上一致;游客没有账号,不替匿名身份保存个人信息,邮箱只存在游客自己的浏览器里。
ALTER TABLE members
ADD COLUMN username text UNIQUE,
ADD COLUMN password_hash text,
ADD CONSTRAINT members_password_check CHECK (password_hash IS NULL OR username IS NOT NULL),
ADD CHECK (password_hash IS NULL OR username IS NOT NULL),
ADD COLUMN email text,
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL);
ADD CHECK (email IS NULL OR username IS NOT NULL);
-- site 表没有行或者 brand_name 为 NULL 时,品牌名是默认的 Alisten。site 表没有行时开放注册。
-- site 表没有行或者 brand_name 为 NULL 时,品牌名是默认的 Alisten;没有行时开放注册。
ALTER TABLE site
ALTER COLUMN brand_name DROP NOT NULL,
ADD COLUMN registration_open boolean NOT NULL DEFAULT true;
+2 -2
View File
@@ -2,7 +2,7 @@
CREATE TABLE playlists (
id uuid PRIMARY KEY DEFAULT uuidv7(),
owner_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
-- 每个账号恰好有一个“我喜欢”,在注册时创建,不能改名或删除。
-- 每个账号恰好一个“我喜欢”:注册时创建,不能改名或删除。
kind text NOT NULL CHECK (kind IN ('favorites', 'custom')),
name text NOT NULL,
-- 新加入的曲目取 item_seq + 1 作为排序值,排在最前。
@@ -15,7 +15,7 @@ CREATE TABLE playlists (
CREATE INDEX ON playlists (owner_id);
CREATE UNIQUE INDEX ON playlists (owner_id) WHERE kind = 'favorites';
-- 展示用的元数据存一份快照,因为歌单里的歌不一定准备过,tracks 表里可能没有。
-- 展示用的元数据存一份快照:歌单里的歌不一定准备过,tracks 表里可能没有。
CREATE TABLE playlist_items (
playlist_id uuid NOT NULL REFERENCES playlists ON DELETE CASCADE,
source text NOT NULL,
-21
View File
@@ -1,21 +0,0 @@
-- +goose Up
-- 关闭之后不再记录新的播放历史,已有的保留。
ALTER TABLE members ADD COLUMN record_listens boolean NOT NULL DEFAULT true;
-- 成员在房间里听到过的歌,每首只留最近一次。元数据存一份快照,不关联 tracks,因为管理员删除已存储的媒体时会连带删掉 tracks 中对应的行。
CREATE TABLE listens (
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
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,
page_url text NOT NULL,
heard_at timestamptz NOT NULL,
PRIMARY KEY (member_id, source, track_id)
);
CREATE INDEX ON listens (member_id, heard_at DESC);
-3
View File
@@ -1,3 +0,0 @@
-- +goose Up
-- 分享链接中的随机串,NULL 表示没有分享。明文保存,这样所有者随时可以再复制一次链接。
ALTER TABLE playlists ADD COLUMN share_id text UNIQUE;
-6
View File
@@ -1,6 +0,0 @@
-- +goose Up
-- 绑定了 GitHub 的成员也是已注册的账号,可以没有用户名和密码。所以账号的邮箱也可以属于只绑定了 GitHub 的成员。
ALTER TABLE members
ADD COLUMN github_id bigint UNIQUE,
DROP CONSTRAINT members_email_check,
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL OR github_id IS NOT NULL);
+2 -2
View File
@@ -1,6 +1,6 @@
-- name: LockRoomBot :one
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE,因为同一个房间的重置要依次进行。
-- 这样同时到达的首次生成只会创建一个 bot 成员,最后只留下一把 Key。
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个房间的重置要依次进行,
-- 同时到达的首次生成才只会创建一个 bot 成员,最后只留下一把 Key。
SELECT bot_member_id FROM rooms WHERE id = @id FOR NO KEY UPDATE;
-- name: SetRoomBot :exec
+1 -1
View File
@@ -1,5 +1,5 @@
-- name: AutoDJSeeds :many
-- 只看最近 1000 次播放。查询按 plays (room_id, started_at) 的索引倒序读,读到 1000 行就停,所以耗时与历史的长度无关。
-- 只看最近 1000 次播放:按 plays (room_id, started_at) 的索引倒序读到 1000 行就停,耗时与历史的长度无关。
SELECT track_id FROM (
SELECT DISTINCT ON (track_id) track_id, tier, rank
FROM (
-57
View File
@@ -1,57 +0,0 @@
-- name: LockListeners :many
-- 按 ID 的顺序锁住开启了记录的成员。这样同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删。
-- 加锁的顺序固定,所以同时写几个成员的事务之间不会死锁。
SELECT id FROM members WHERE id = ANY(@ids::uuid[]) AND record_listens ORDER BY id FOR NO KEY UPDATE;
-- name: UpsertListens :exec
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT m, @source::text, @track_id::text, @title::text, sqlc.narg(artist)::text, sqlc.narg(album)::text, sqlc.narg(cover_url)::text,
sqlc.narg(cover_key)::text, @duration_ms::bigint, @page_url::text, @heard_at::timestamptz
FROM unnest(@member_ids::uuid[]) AS m
ON CONFLICT (member_id, 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, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: MoveListens :exec
-- 并入的规则与 UpsertListens 相同,并入的那份算新写入的。to_id 关闭了记录时只删不并。
WITH moved AS (
DELETE FROM listens WHERE member_id = sqlc.arg(from_id)::uuid
RETURNING source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
)
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT sqlc.arg(to_id)::uuid, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM moved
WHERE EXISTS (SELECT FROM members WHERE id = sqlc.arg(to_id)::uuid AND record_listens)
ON CONFLICT (member_id, 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, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: TrimListens :exec
-- 排序与 MemberListens 相同,所以留下的正是读取时返回的那些。
DELETE FROM listens l
USING (
SELECT member_id, source, track_id,
row_number() OVER (PARTITION BY member_id ORDER BY heard_at DESC, source, track_id) AS n
FROM listens WHERE member_id = ANY(@member_ids::uuid[])
) ranked
WHERE l.member_id = ranked.member_id AND l.source = ranked.source AND l.track_id = ranked.track_id AND ranked.n > @keep::bigint;
-- name: MemberListens :many
SELECT source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM listens WHERE member_id = @member_id
ORDER BY heard_at DESC, source, track_id
LIMIT @max;
-- name: DeleteListen :exec
DELETE FROM listens WHERE member_id = @member_id AND source = @source AND track_id = @track_id;
-- name: ClearListens :exec
DELETE FROM listens WHERE member_id = @member_id;
-- name: ListenRecording :one
SELECT record_listens FROM members WHERE id = @id;
-- name: SetListenRecording :execrows
UPDATE members SET record_listens = @record_listens WHERE id = @id;
+4 -13
View File
@@ -1,19 +1,10 @@
-- name: ListStoredMedia :many
-- 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\'
ORDER BY CASE WHEN @sort::text = 'stored' THEN prepared_at END DESC,
CASE WHEN @sort::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
CASE WHEN @sort::text = 'size' THEN media_size END DESC,
track_id COLLATE "C"
LIMIT @page_size::bigint OFFSET @skip::bigint;
-- name: CountStoredMedia :one
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\';
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, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
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
+8 -33
View File
@@ -9,67 +9,42 @@ INSERT INTO member_sessions (token_hash, member_id) VALUES (@token_hash, @member
SELECT * FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = @token_hash);
-- name: UpdateMember :one
-- 只覆盖传入的列,所以分别修改昵称和头像的两个并发请求不会互相还原。头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
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,
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
email = CASE WHEN username IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
WHERE id = @id
RETURNING *;
-- name: MemberAccountProfile :one
SELECT username, email, github_id FROM members WHERE id = @id;
SELECT username, email FROM members WHERE id = @id;
-- name: RegisterMember :execrows
-- 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码时还要删除其他会话,所以用 AddMemberCredentials。
-- 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL AND github_id IS NULL;
-- name: AddMemberCredentials :execrows
-- 只给只绑定了 GitHub 的账号设置。这种账号的“我喜欢”已经在绑定时创建了。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL AND github_id IS NOT NULL;
WHERE id = @id AND username IS NULL;
-- name: AccountByUsername :one
SELECT * FROM members WHERE username = @username;
-- name: AccountByID :one
SELECT * FROM members WHERE id = @id AND (username IS NOT NULL OR github_id IS NOT NULL);
-- name: AccountByGitHub :one
SELECT * FROM members WHERE github_id = @github_id;
-- name: BindGitHub :one
-- 只给还没有绑定 GitHub 的成员绑定。已经绑定过的成员不受影响,查询没有返回的行。was_guest 表示成员原来是游客,这次绑定就是注册。
UPDATE members SET github_id = @github_id
WHERE id = @id AND github_id IS NULL
RETURNING (username IS NULL)::boolean AS was_guest;
-- name: UnbindGitHub :execrows
-- 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
UPDATE members SET github_id = NULL WHERE id = @id AND password_hash IS NOT NULL;
-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = @password_hash WHERE id = @id AND username IS NOT NULL;
-- name: LockAccountPassword :one
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE。这样同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id AND username IS NOT NULL FOR NO KEY UPDATE;
-- name: MemberHasSession :one
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = @token_hash AND member_id = @member_id);
-- name: LockMemberPassword :one
-- 这把锁让修改密码等到这次登录提交之后才进行,所以修改密码随后删除会话时,会删掉这次登录签发的会话。
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE,这样同一个账号的几次登录会依次并入游客的历史。否则两次登录会按相反的顺序抢同一批历史行而死锁。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR NO KEY UPDATE;
-- FOR SHARE 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR SHARE;
-- name: DeleteSession :execrows
DELETE FROM member_sessions WHERE token_hash = @token_hash;
-- name: TakeSession :one
DELETE FROM member_sessions WHERE token_hash = @token_hash RETURNING member_id;
-- name: DeleteMemberSessions :many
-- keep 为 NULL 时删除这个成员的全部会话。
DELETE FROM member_sessions WHERE member_id = @member_id AND token_hash IS DISTINCT FROM sqlc.narg(keep)::bytea
+4 -38
View File
@@ -2,7 +2,7 @@
INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'favorites', '我喜欢');
-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id
@@ -17,7 +17,7 @@ WHERE p.owner_id = @owner_id AND (i.cover_url IS NOT NULL OR i.cover_key IS NOT
ORDER BY i.playlist_id, i.position DESC;
-- name: LockFavorites :exec
-- 新建歌单前锁住这个账号的“我喜欢”,这样同一个账号并发新建时会依次计数,不会越过上限。
-- 新建歌单前锁住这个账号的“我喜欢”:同一个账号并发新建时依次计数,不会越过上限。
SELECT id FROM playlists WHERE owner_id = @owner_id AND kind = 'favorites' FOR UPDATE;
-- name: CountPlaylists :one
@@ -37,7 +37,7 @@ UPDATE playlists SET name = @name, updated_at = now() WHERE id = @id AND owner_i
DELETE FROM playlists WHERE id = @id AND owner_id = @owner_id AND kind = 'custom';
-- name: LockPlaylist :one
-- 改动曲目前锁住歌单,这样并发加入时会依次检查上限、分配排序值。删除歌单时先锁歌单行再级联删除曲目,所以其他操作也要先锁歌单再动曲目,否则会互相死锁。
-- 改动曲目前锁住歌单:并发加入时依次检查上限、分配排序值。删除歌单先锁歌单行再级联删除曲目,其他操作也要先锁歌单再动曲目,否则会互相死锁。
SELECT item_seq FROM playlists WHERE id = @id AND owner_id = @owner_id FOR UPDATE;
-- name: PlaylistHasItem :one
@@ -50,10 +50,6 @@ SELECT count(*) FROM playlist_items WHERE playlist_id = @playlist_id;
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
-- name: InsertPlaylistItems :copyfrom
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
-- name: SetPlaylistSeq :exec
UPDATE playlists SET item_seq = @item_seq, updated_at = now() WHERE id = @id;
@@ -71,7 +67,7 @@ WITH deleted AS (
UPDATE playlists SET updated_at = now() WHERE id IN (SELECT playlist_id FROM deleted);
-- name: CopyPlaylistItems :execrows
-- 按请求的先后顺序复制,第一首的排序值最大。请求里重复的歌只取第一次出现的位置。
-- 按请求的先后复制:第一首的排序值最大。请求里重复的歌只取第一次出现的位置。
WITH wanted AS (
SELECT s.source, k.track_id, min(s.ord) AS ord
FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
@@ -108,33 +104,3 @@ SELECT p.id FROM playlists p
JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id AND i.source = @source AND i.track_id = @track_id
ORDER BY p.id;
-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = @share_id WHERE id = @id AND owner_id = @owner_id;
-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = sqlc.arg(share_id)::text;
-- name: CopySharedPlaylistItems :one
-- 确认分享还在和复制曲目放在同一条语句里,两者看到的是同一个快照,所以要么完整复制,要么读不到分享。不锁来源歌单,因为两个人同时复制
-- 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
WITH shared AS (
SELECT id FROM playlists WHERE share_id = sqlc.arg(share_id)::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT sqlc.arg(to_id), i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied;
-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = @playlist_id AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1;
+1 -4
View File
@@ -1,5 +1,5 @@
-- name: LockRoomPassword :one
-- FOR SHARE 让修改密码和删除房间等到这次签发提交之后才进行。这样两者随后删除票据时,会把这一张也删掉。
-- FOR SHARE 让修改密码和删除房间等到这次签发提交之后,它们随后删除票据时会删掉这一张。
SELECT coalesce(password_hash, '')::text AS password_hash FROM rooms WHERE id = @id FOR SHARE;
-- name: CreateTicket :exec
@@ -13,6 +13,3 @@ 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;
-- name: ExistingTickets :many
SELECT token_hash FROM room_tickets WHERE token_hash = ANY(@token_hashes::bytea[]);
+1 -7
View File
@@ -3,7 +3,7 @@ SELECT title, artist, album, cover_url, cover_key, page_url, duration_ms, gain,
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, lyric_words, lyric_trans, media_key, media_size)
VALUES (@source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @page_url, @duration_ms, @gain, @lyric, @lyric_words, @lyric_trans, @media_key, @media_size)
ON CONFLICT (source, track_id) DO UPDATE SET
@@ -12,9 +12,3 @@ ON CONFLICT (source, track_id) DO UPDATE SET
lyric = excluded.lyric, lyric_words = excluded.lyric_words, lyric_trans = excluded.lyric_trans,
media_key = excluded.media_key, media_size = excluded.media_size,
prepared_at = now();
-- name: TrackMetas :many
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id;
+2
View File
@@ -63,6 +63,8 @@ spec:
value: {{ printf "https://%s" (include "alisten.domain" .) | quote }}
- name: ALISTEN_ALLOWED_ORIGINS
value: {{ .Values.allowedOrigins | quote }}
- name: ALISTEN_TRUSTED_PROXIES
value: {{ .Values.trustedProxies | quote }}
- name: STORAGE_PUBLIC_URL
value: {{ required "storage.publicUrl 不能为空" .Values.storage.publicUrl | quote }}
{{- include "alisten.databaseEnv" . | nindent 12 }}
+2
View File
@@ -1,5 +1,7 @@
domain: ""
allowedOrigins: ""
# 逗号分隔的 CIDR:只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信,用于按客户端 IP 限流。
trustedProxies: ""
logLevel: info
otlpEndpoint: ""
database:
+4 -35
View File
@@ -12,7 +12,7 @@ func (s *server) register(ctx context.Context, c call) (any, error) {
if err != nil {
return nil, err
}
account, err := s.Accounts.Register(ctx, c.member.ID, bearer(c.r), c.clientIP, req.Username, req.Password)
account, err := s.Accounts.Register(ctx, c.member.ID, c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
@@ -24,11 +24,11 @@ func (s *server) login(ctx context.Context, c call) (any, error) {
if err != nil {
return nil, err
}
session, account, device, err := s.Accounts.Login(ctx, c.member, bearer(c.r), c.clientIP, req.Username, req.Password, deref(req.Device))
session, account, err := s.Accounts.Login(ctx, c.member, bearer(c.r), c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
return protocol.LoginResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account), Device: device}, nil
return protocol.LoginResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account)}, nil
}
func (s *server) logout(ctx context.Context, c call) (any, error) {
@@ -70,39 +70,8 @@ func (s *server) adminResetAccountPassword(ctx context.Context, c call) (any, er
return protocol.AdminResetAccountPasswordResponse{Password: password}, nil
}
func (s *server) createPairingCode(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeCreatePairingCodeRequest(c.body); err != nil {
return nil, err
}
code, err := s.Accounts.CreatePairingCode(ctx, c.member.ID, bearer(c.r))
if err != nil {
return nil, err
}
// 配对码放在片段里,不会出现在请求和 Referer 中。
image, err := qrDataURI(s.PublicURL + "/pair#" + code.Code)
if err != nil {
return nil, err
}
return protocol.CreatePairingCodeResponse{Code: code.Code, QrImage: image, ExpiresAtMs: code.ExpiresAt.UnixMilli()}, nil
}
func (s *server) redeemPairingCode(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRedeemPairingCodeRequest(c.body)
if err != nil {
return nil, err
}
session, account, err := s.Accounts.RedeemPairingCode(ctx, c.member, bearer(c.r), c.clientIP, req.Code)
if err != nil {
return nil, err
}
return protocol.RedeemPairingCodeResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account)}, nil
}
func accountDTO(a identity.Account) protocol.Account {
dto := protocol.Account{Github: a.GitHub}
if a.Username != "" {
dto.Username = &a.Username
}
dto := protocol.Account{Username: a.Username}
if a.Email != "" {
dto.Email = &a.Email
}
+16 -50
View File
@@ -8,6 +8,7 @@ import (
"io"
"log/slog"
"net/http"
"net/netip"
"runtime/debug"
"slices"
"strings"
@@ -19,7 +20,6 @@ import (
"github.com/dihemoe/alisten/internal/app/admin"
"github.com/dihemoe/alisten/internal/app/catalog"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/listen"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/playlist"
@@ -37,9 +37,7 @@ const (
maxBodyBytes = 64 << 10
// 歌单的批量接口一次最多 1000 首,链接曲目的 ID 最长 2048 字节,每首再留 256 字节给来源、字段名和 JSON 的标点。
maxBatchBodyBytes = 1000 * (2048 + 256)
// 导入文件一次最多 1000 首,每首除了曲目还带标题、歌手、专辑和两个地址。nginx 中 /api/me/playlists/imports 的上限要与它保持一致。
maxImportBodyBytes = 1 << 20
requestDeadline = 3 * time.Minute
requestDeadline = 3 * time.Minute
)
type Services struct {
@@ -53,13 +51,12 @@ type Services struct {
Media *media.Library
Playback *media.Playback
Site *site.Service
Listens *listen.Service
GitHub *identity.GitHub
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
StorageURL string
// 网页的 origin,结尾没有 /。
PublicURL string
// 只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信。
TrustedProxies []netip.Prefix
}
type server struct {
@@ -76,7 +73,7 @@ type call struct {
member member.Member
roomID string
ticket string
// 持有这张票据的成员会话的 token 哈希。API Key 没有会话。
// 票据签发给的成员会话的 token 哈希,API Key 没有会话。
memberSession []byte
// 票据是常驻房间的 API Key。
bot bool
@@ -127,9 +124,6 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists)
srv.handleLimit(mux, protocol.RouteCopyMyPlaylistItems, maxBatchBodyBytes, srv.copyMyPlaylistItems)
srv.handleLimit(mux, protocol.RouteMoveMyPlaylistItems, maxBatchBodyBytes, srv.moveMyPlaylistItems)
srv.handleLimit(mux, protocol.RouteImportMyPlaylist, maxImportBodyBytes, srv.importMyPlaylist)
srv.handle(mux, protocol.RouteResolveMyPlaylistLink, srv.resolveMyPlaylistLink)
srv.handle(mux, protocol.RouteImportMyPlatformPlaylist, srv.importMyPlatformPlaylist)
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -156,43 +150,25 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminGetYoutubeCredential, srv.adminGetYoutubeCredential)
srv.handle(mux, protocol.RouteAdminSetYoutubeCredential, srv.adminSetYoutubeCredential)
srv.handle(mux, protocol.RouteAdminDeleteYoutubeCredential, srv.adminDeleteYoutubeCredential)
srv.handle(mux, protocol.RouteAdminGetGithubCredential, srv.adminGetGithubCredential)
srv.handle(mux, protocol.RouteAdminSetGithubCredential, srv.adminSetGithubCredential)
srv.handle(mux, protocol.RouteAdminDeleteGithubCredential, srv.adminDeleteGithubCredential)
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
srv.handle(mux, protocol.RouteAdminRemoveMedia, srv.adminRemoveMedia)
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
srv.handle(mux, protocol.RouteAdminOpenRegistration, srv.adminOpenRegistration)
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
srv.handle(mux, protocol.RouteAdminGetAccount, srv.adminGetAccount)
srv.handle(mux, protocol.RouteAdminResetAccountPassword, srv.adminResetAccountPassword)
srv.handle(mux, protocol.RouteListMyListens, srv.listMyListens)
srv.handle(mux, protocol.RouteRemoveMyListen, srv.removeMyListen)
srv.handle(mux, protocol.RouteClearMyListens, srv.clearMyListens)
srv.handle(mux, protocol.RouteEnableListenRecording, srv.enableListenRecording)
srv.handle(mux, protocol.RouteDisableListenRecording, srv.disableListenRecording)
srv.handle(mux, protocol.RouteShareMyPlaylist, srv.shareMyPlaylist)
srv.handle(mux, protocol.RouteUnshareMyPlaylist, srv.unshareMyPlaylist)
srv.handle(mux, protocol.RouteGetSharedPlaylist, srv.getSharedPlaylist)
srv.handle(mux, protocol.RouteCopySharedPlaylist, srv.copySharedPlaylist)
srv.handle(mux, protocol.RouteCreatePairingCode, srv.createPairingCode)
srv.handle(mux, protocol.RouteRedeemPairingCode, srv.redeemPairingCode)
srv.handle(mux, protocol.RouteStartGithubLogin, srv.startGithubLogin)
srv.handle(mux, protocol.RouteFinishGithubLogin, srv.finishGithubLogin)
srv.handle(mux, protocol.RouteUnbindGithub, srv.unbindGithub)
mux.HandleFunc("GET "+GitHubCallbackPath, srv.githubCallback)
mux.HandleFunc("GET /ws", srv.websocket)
mux.HandleFunc("GET /api/room/events", srv.roomEvents)
mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia)
// HTTP 的错误响应体是 JSON,而 ServeMux 自带的 404、405 响应是纯文本。不带方法的兜底路由也会匹配方法不符的请求,
// 所以这些请求得到的也是 404,因为错误码里没有和 405 对应的。
// 因此它们得到 404:错误码中没有与 405 对应的。
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
writeError(w, protocol.ErrorInfo{Code: protocol.ErrorCodeNotFound, Message: "接口不存在"})
})
return mux
}
// 不带 Origin 的请求不检查,因为 bot 从服务端发起的调用没有 Origin。
// 不带 Origin 的请求不检查:bot 从服务端发起的调用没有 Origin。
func SameOrigin(allowedOrigins []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" && !slices.Contains(allowedOrigins, origin) {
@@ -232,15 +208,15 @@ func (s *server) handleLimit(mux *http.ServeMux, route protocol.Route, maxBody i
})
}
// 先鉴权再读请求体,这样后台没有启用时,不管请求是否合法都返回 FORBIDDEN。返回的 ctx 带着凭证所属的成员和房间,
// 因为出错时的日志也要带上它们。
// 先鉴权再读请求体:后台没有启用时,无论请求是否合法都返回 FORBIDDEN。返回的 ctx 带有凭证所属的成员和房间,
// 出错时的日志也要带上它们。
func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, maxBody int64, fn handlerFunc) (logCtx context.Context, _ any, err error) {
defer func() {
if value := recover(); value != nil {
logCtx, err = ctx, s.panicked(ctx, r, value)
}
}()
c := call{r: r, clientIP: clientIP(r)}
c := call{r: r, clientIP: clientIP(r, s.TrustedProxies)}
switch auth {
case protocol.AuthNone:
case protocol.AuthMember:
@@ -297,8 +273,8 @@ var errUnreadableBody = errors.New("无法读取请求体")
// 恢复 panic 的地方已经记录了日志,回复时不再重复记录。
var errPanicked = errors.New("服务器出错了,请稍后再试。")
// 无法读取请求体是客户端的问题,不是内部错误。如果是连接读取失败,net/http 在返回错误之前已经取消了
// r.Context(),这时按客户端断开处理。如果连接还在,就是请求体本身不合法,比如分块编码有误。
// 无法读取请求体是客户端的问题,不是内部错误。连接读取失败时,net/http 在返回错误之前已经取消了
// r.Context(),此时按客户端断开处理;连接仍在时,是请求体本身不合法,比如分块编码有误。
func bodyError(r *http.Request, err error) error {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
@@ -326,7 +302,9 @@ var knownErrors = []struct {
{errUnreadableBody, protocol.ErrorCodeInvalidArgument},
{port.ErrRoomNotFound, protocol.ErrorCodeNotFound},
{port.ErrSlugTaken, protocol.ErrorCodeConflict},
{media.ErrNotFound, protocol.ErrorCodeNotFound},
{media.ErrNotInRoom, protocol.ErrorCodeNotFound},
{media.ErrInUse, protocol.ErrorCodeConflict},
{identity.ErrInvalidMemberToken, protocol.ErrorCodeUnauthorized},
{identity.ErrWrongPassword, protocol.ErrorCodeUnauthorized},
{admin.ErrInvalidToken, protocol.ErrorCodeUnauthorized},
@@ -352,9 +330,6 @@ var knownErrors = []struct {
{playlist.ErrPlaylistFull, protocol.ErrorCodeConflict},
{playlist.ErrTrackNotReady, protocol.ErrorCodeConflict},
{playlist.ErrTooFast, protocol.ErrorCodeRateLimited},
{playlist.ErrInvalidPlaylistLink, protocol.ErrorCodeInvalidArgument},
{playlist.ErrUnsupportedPlaylist, protocol.ErrorCodeInvalidArgument},
{playlist.ErrShortLinkFailed, protocol.ErrorCodeProviderError},
{playlistorder.ErrBeforeMoved, protocol.ErrorCodeInvalidArgument},
{playlistorder.ErrBeforeMissing, protocol.ErrorCodeConflict},
{realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument},
@@ -366,15 +341,6 @@ var knownErrors = []struct {
{catalog.ErrRateLimited, protocol.ErrorCodeRateLimited},
{errClientOutdated, protocol.ErrorCodeClientOutdated},
{errPanicked, protocol.ErrorCodeInternal},
{port.ErrShareNotFound, protocol.ErrorCodeNotFound},
{identity.ErrInvalidPairingCode, protocol.ErrorCodeInvalidCredentials},
{port.ErrNoPassword, protocol.ErrorCodeConflict},
{identity.ErrGitHubLoginExpired, protocol.ErrorCodeInvalidCredentials},
{identity.ErrGitHubMismatch, protocol.ErrorCodeInvalidCredentials},
{identity.ErrGitHubUnverified, protocol.ErrorCodeForbidden},
{identity.ErrGitHubDisabled, protocol.ErrorCodeNotFound},
{port.ErrGitHubTaken, protocol.ErrorCodeConflict},
{port.ErrGitHubAlreadyBound, protocol.ErrorCodeConflict},
}
func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
+4 -7
View File
@@ -35,23 +35,20 @@ import (
const allowedOrigin = "https://allowed.example"
// 没有调用 realtime.Start,因为这些测试不运行房间。
// 没有调用 realtime.Start:这些测试不运行房间。
func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string) Services {
music := newCatalog(rooms, 10)
node := porttest.NewRedis().Node("a")
_, gateway := realtime.New(store, rooms, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
_, gateway := realtime.New(store, rooms, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
l := lobby.New(rooms, node)
accounts := identity.NewAccounts(store, store, nil, nil, nil, nil, gateway)
return Services{
Identity: identity.New(store, rooms, store, gateway),
Accounts: accounts,
Accounts: identity.NewAccounts(store, store, nil, gateway, slog.New(slog.DiscardHandler)),
Lobby: l,
Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
Gateway: gateway,
Catalog: music,
Site: site.New(store),
// 这些测试不发起 GitHub 授权,只读取后台是否配置了 OAuth App。
GitHub: identity.NewGitHub(accounts, store, nil, nil),
}
}
@@ -161,7 +158,7 @@ func TestRequestBodyLimit(t *testing.T) {
func TestPlaylistBatchBodyLimit(t *testing.T) {
store := porttest.New()
s := services(store, store, "")
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil, nil)
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil)
server, _ := start(t, s)
var created protocol.CreateMemberResponse
if err := json.Unmarshal([]byte(send(t, server, http.MethodPost, "/api/members", "{}").body), &created); err != nil {
+1 -1
View File
@@ -41,7 +41,7 @@ func (s *server) answer(ctx context.Context, roomID string, data any) (resp any,
func summaries(songs []port.Song) []protocol.TrackSummary {
items := make([]protocol.TrackSummary, len(songs))
for i, s := range songs {
items[i] = protocol.TrackSummary{Track: trackDTO(s.Track), Meta: meta(s.Meta)}
items[i] = protocol.TrackSummary{Track: protocol.Track{Source: protocol.Source(s.Track.Source), ID: s.Track.ID}, Meta: meta(s.Meta)}
}
return items
}
-4
View File
@@ -17,10 +17,6 @@ func (noTracks) PreparedTrack(context.Context, track.Track) (port.PreparedTrack,
return port.PreparedTrack{}, false, nil
}
func (noTracks) TrackMetas(context.Context, []track.Track) (map[track.Track]track.Meta, error) {
return nil, nil
}
func (noTracks) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
return nil
}
+9 -8
View File
@@ -3,19 +3,20 @@ package api
import (
"net/http"
"net/netip"
"slices"
"strings"
)
// 只有对端是回环、私有网段或链路本地地址的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理,
// 第一个不受信任的地址就是客户端。如果全都受信任,就取最左端的地址。受信任的代理只会写入合法的地址,
// 所以遇到不合法的值,说明已经到了客户端自己填写的部分,这时改用对端地址。
func clientIP(r *http.Request) string {
// 只有对端是受信任的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理,
// 第一个不受信任的地址就是客户端;全部受信任时取最左端的地址。受信任的代理只会写入合法的地址,
// 遇到不合法的值说明已经到了客户端自己填写的部分,此时改用对端地址。
func clientIP(r *http.Request, trusted []netip.Prefix) string {
peer, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
addr := peer.Addr().Unmap()
if !trustedProxy(addr) {
if !containsAddr(trusted, addr) {
return addr.String()
}
var hops []string
@@ -30,13 +31,13 @@ func clientIP(r *http.Request) string {
break
}
hop = hop.Unmap()
if i == 0 || !trustedProxy(hop) {
if i == 0 || !containsAddr(trusted, hop) {
return hop.String()
}
}
return addr.String()
}
func trustedProxy(addr netip.Addr) bool {
return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()
func containsAddr(prefixes []netip.Prefix, addr netip.Addr) bool {
return slices.ContainsFunc(prefixes, func(p netip.Prefix) bool { return p.Contains(addr) })
}
+16 -10
View File
@@ -2,29 +2,27 @@ package api
import (
"net/http/httptest"
"net/netip"
"testing"
)
func TestClientIP(t *testing.T) {
trusted := []netip.Prefix{netip.MustParsePrefix("10.42.0.0/16"), netip.MustParsePrefix("127.0.0.1/32")}
cases := []struct {
name string
remote string
forwarded []string
want string
}{
{"公网对端不看 X-Forwarded-For", "203.0.113.7:4321", []string{"198.51.100.1"}, "203.0.113.7"},
{"私有网段的代理转发时取公网客户端地址", "10.42.0.153:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"回环对端同样可信", "127.0.0.1:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"IPv6 回环对端同样可信", "[::1]:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"链路本地对端同样可信", "[fe80::1]:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"链式代理跳过右端的私有地址", "127.0.0.1:4321", []string{"203.0.113.7, 10.42.0.1, 192.168.1.2"}, "203.0.113.7"},
{"对端不受信任时不看 X-Forwarded-For", "203.0.113.7:4321", []string{"198.51.100.1"}, "203.0.113.7"},
{"受信任的代理转发时取客户端地址", "10.42.0.153:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"跳过右端受信任的代理", "127.0.0.1:4321", []string{"203.0.113.7, 10.42.0.1"}, "203.0.113.7"},
{"客户端自己填在左边的地址不采用", "10.42.0.153:4321", []string{"198.51.100.1, 203.0.113.7"}, "203.0.113.7"},
{"多个头按顺序拼接", "10.42.0.153:4321", []string{"198.51.100.1", "203.0.113.7"}, "203.0.113.7"},
{"全部是私有地址时取最左端", "10.42.0.153:4321", []string{"192.168.1.5, 10.42.0.1"}, "192.168.1.5"},
{"全部受信任时取最左端", "10.42.0.153:4321", []string{"10.42.0.1"}, "10.42.0.1"},
{"没有 X-Forwarded-For 时取对端", "10.42.0.153:4321", nil, "10.42.0.153"},
{"遇到不合法的地址就停下,取对端", "10.42.0.153:4321", []string{"203.0.113.7, unknown, 10.42.0.1"}, "10.42.0.153"},
{"IPv4 映射的 IPv6 对端按 IPv4 处理", "[::ffff:10.42.0.153]:4321", []string{"::ffff:203.0.113.7"}, "203.0.113.7"},
{"IPv4 映射的 IPv6 私有跳按 IPv4 处理", "10.42.0.153:4321", []string{"203.0.113.7, ::ffff:10.42.0.1"}, "203.0.113.7"},
{"IPv4 映射的 IPv6 地址按 IPv4 处理", "[::ffff:10.42.0.153]:4321", []string{"::ffff:203.0.113.7"}, "203.0.113.7"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -33,9 +31,17 @@ func TestClientIP(t *testing.T) {
for _, v := range tc.forwarded {
r.Header.Add("X-Forwarded-For", v)
}
if got := clientIP(r); got != tc.want {
if got := clientIP(r, trusted); got != tc.want {
t.Errorf("clientIP = %q,期望 %q", got, tc.want)
}
})
}
t.Run("没有配置受信任的代理", func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = "10.42.0.153:4321"
r.Header.Set("X-Forwarded-For", "203.0.113.7")
if got := clientIP(r, nil); got != "10.42.0.153" {
t.Errorf("clientIP = %q,期望对端地址", got)
}
})
}
+5 -9
View File
@@ -24,7 +24,7 @@ var roomErrorCodes = map[room.ErrorKind]protocol.ErrorCode{
room.NothingPlaying: protocol.ErrorCodeNothingPlaying,
}
// bot 表示 by 是常驻房间的 bot 成员。返回的成员是房间命令的发起人。如果 bot 代填了点歌人,返回的成员 ID 不变,名字换成点歌人的名字加上“(bot)”。
// bot 表示 by 是常驻房间的 bot 成员。返回的成员是房间命令的发起人,bot 代填了点歌人时换成那个名字。
func (s *server) action(ctx context.Context, roomID string, by member.Member, bot bool, data any) (room.Action, member.Member, error) {
switch d := data.(type) {
case protocol.RoomSyncRequest:
@@ -141,7 +141,7 @@ func (s *server) mediaURL(t track.Track) string {
return "/media/" + string(t.Source) + "/" + url.PathEscape(t.ID)
}
// 链接按点歌时的写法规范化,因为曲目表和歌单里的链接曲目用的都是规范化之后的 ID。
// 链接按点歌时的写法规范化:曲目表和歌单里的链接曲目都用规范化之后的 ID。
func trackOf(d protocol.Track) track.Track {
t := track.Track{Source: track.Source(d.Source), ID: d.ID}
if t.Source == track.Link {
@@ -150,7 +150,7 @@ func trackOf(d protocol.Track) track.Track {
return t
}
// 封面存在对象存储里的曲目,对外的封面地址用 StorageURL 拼出。
// 封面在对象存储中的曲目,对外的封面地址由公开地址拼出。
func (s *server) trackMeta(m track.Meta) protocol.TrackMeta {
d := meta(m)
d.CoverURL = s.coverURL(m.CoverURL, m.CoverKey)
@@ -164,14 +164,10 @@ func (s *server) coverURL(url string, key track.ObjectKey) string {
return url
}
func trackDTO(t track.Track) protocol.Track {
return protocol.Track{Source: protocol.Source(t.Source), ID: t.ID}
}
func (s *server) entry(e room.Entry) protocol.Entry {
d := protocol.Entry{
ID: strconv.FormatUint(uint64(e.ID), 10),
Track: trackDTO(e.Track),
Track: protocol.Track{Source: protocol.Source(e.Track.Source), ID: e.Track.ID},
Meta: s.trackMeta(e.Meta),
Auto: e.Auto,
Likes: int64(e.Likes),
@@ -238,7 +234,7 @@ func (s *server) activity(a room.Activity) protocol.RoomActivityEvent {
return d
}
// 歌名里已经有书名号时不再加一层,因为 B 站的标题常带《》。
// 歌名里已经有书名号时不再加一层:B 站的标题常带《》。
func bookTitle(title string) string {
if strings.ContainsAny(title, "《》") {
return title
+11 -9
View File
@@ -2,6 +2,7 @@ package api
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
@@ -9,15 +10,14 @@ import (
"strconv"
"time"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/protocol"
)
const (
// 心跳间隔小于入口的读超时,因为 nginx 默认 60 秒收不到数据就断开。
// 小于入口的读超时:nginx 默认 60 秒收不到数据就断开。
streamHeartbeat = 25 * time.Second
// 客户端不再读取时,写入在这个期限之后失败,事件流随之结束。否则关闭事件流(包括退出时)要一直等着阻塞的写入。
// 客户端不再读取时,写入在这个期限之后失败,事件流随之结束:否则关闭事件流(包括退出时)要一直等阻塞的写入。
streamWriteTimeout = 10 * time.Second
)
@@ -26,7 +26,7 @@ var streamCloses = map[realtime.CloseReason]streamClose{
realtime.CloseRevoked: {Code: protocol.CloseUnauthorized, Message: "凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"},
realtime.CloseRoomGone: {Code: protocol.CloseNotFound, Message: "房间已关闭或被删除"},
realtime.CloseOverflow: {Code: protocol.CloseSendQueueFull, Message: "积压的事件过多,请重新连接"},
// 不用 1012,因为共用一把 Key 的两个 bot 收到 4409 就该停下,而不是重连之后互相顶掉。
// 不是 1012:共用一把 Key 的两个 bot 看到它就该停下,而不是重连之后互相顶掉。
realtime.CloseReplaced: {Code: 4409, Message: "同一凭证打开了新的事件流"},
}
@@ -35,8 +35,8 @@ type streamClose struct {
Message string `json:"message"`
}
// 房间的事件流先发 snapshot,data 和 room.sync 的响应相同。之后是房间事件,data 和 WebSocket 上的报文相同。
// 结束之前发 close,code 沿用 WebSocket 的关闭码,被同一凭证的新事件流顶替时是 4409。每次连接都从新快照开始,
// 房间的事件流:先发 snapshot,data 与 room.sync 的响应相同;之后是房间事件,data 与 WebSocket 上的报文相同;
// 结束之前发 close,code 沿用 WebSocket 的关闭码,让给同一凭证的新事件流时是 4409。每次连接都从新快照开始,
// 不按 Last-Event-ID 补发断开期间的事件。
func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
// 打开事件流的期限与 WebSocket 的握手相同,打开之后的推送不设期限。
@@ -81,7 +81,9 @@ func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
// 确认票据失败或 panic 时都要退订,否则事件流的名额和房间频道一直被占着。
func (s *server) observe(ctx context.Context, c call) (any, error) {
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.memberSession, c.bot, string(identity.TicketCredential(c.ticket)))
// 内存中只留凭证的哈希。
credential := sha256.Sum256([]byte(c.ticket))
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.memberSession, c.bot, string(credential[:]))
if err != nil {
return nil, err
}
@@ -91,7 +93,7 @@ func (s *server) observe(ctx context.Context, c call) (any, error) {
s.Gateway.Unobserve(o)
}
}()
// 作废票据时先删除票据,再通知断开。如果通知发出时事件流还没在本实例生效,事件流就收不到,所以生效之后再确认一次票据。
// 作废时先删除票据、再通知断开:事件流在本实例生效之前发出的通知它收不到,所以生效之后再确认一次票据。
if _, err := s.Identity.VerifyTicket(ctx, c.ticket); err != nil {
return nil, err
}
@@ -116,7 +118,7 @@ type eventStream struct {
logger *slog.Logger
}
// data 只占一行,因为 json.Marshal 的输出里没有换行。
// data 只占一行:json.Marshal 的输出中没有换行。
func (st eventStream) event(ctx context.Context, name, id string, data any) bool {
payload, err := json.Marshal(data)
if err != nil {
+3 -37
View File
@@ -150,40 +150,6 @@ func TestRoomEvents(t *testing.T) {
})
}
// 网关定期到存储里核对连接所用的凭证。事件流登记的凭证要和存储里的票据哈希相同,否则仍然有效的事件流也会被断开。
func TestRoomEventsSurviveCredentialCheck(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
f := newWSFixture(t, store, store, store)
hall, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "大厅"})
if err != nil {
t.Fatal(err)
}
key, err := f.admin.ResetRoomAPIKey(t.Context(), hall)
if err != nil {
t.Fatal(err)
}
_, _, ticket := f.enter(t, hall)
bot, member := f.stream(t, key), f.stream(t, ticket)
readEvent(t, bot)
readEvent(t, member)
// 心跳每 25 秒一次,核对每 30 秒一次,所以第二次心跳之前已经核对过一次。
for range 2 {
checkEvent(t, bot, ": ping")
checkEvent(t, member, ": ping")
}
// 不经过 admin 重置,就没有断开的通知,只能靠下一次核对断开。
_, fresh := identity.NewAPIKey()
if err := store.ResetRoomAPIKey(t.Context(), hall, fresh); err != nil {
t.Fatal(err)
}
checkEvent(t, bot, "event: close", `data: {"code":4401,"message":"凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"}`)
checkEnded(t, bot)
checkEvent(t, member, ": ping")
})
}
func TestRoomEventsCloseWithTheRoom(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
@@ -298,7 +264,7 @@ func TestRoomEventsOpeningTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
f := newWSFixtureWithTickets(t, store, stallingTickets{store}, store, store)
// 客户端到期限之后也放弃,这样服务端没有设期限时,测试不会一直等下去。
// 期限之后客户端也放弃:服务端没有期限时,这里不会一直等下去。
ctx, cancel := context.WithTimeout(t.Context(), 2*helloTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://alisten.test/api/room/events", nil)
@@ -322,7 +288,7 @@ func TestRoomEventsOpeningTimesOut(t *testing.T) {
})
}
// 第二次查找票据时发生 panic,也就是事件流生效之后的那次确认。
// 第二次查找票据,即事件流生效之后的确认,发生 panic。
type panickingRecheck struct {
*porttest.Store
lookups atomic.Int32
@@ -369,7 +335,7 @@ func TestRoomEventsEndWhenClientStopsReading(t *testing.T) {
}
_, m, ticket := f.enter(t, hall)
f.stream(t, ticket)
// 内存连接上的写入要等对方读取,所以客户端不读,这个事件就一直写不出去。
// 内存连接上的写入要等对方读取:客户端不读,这个事件就一直写不出去。
if _, err := f.gateway.Do(t.Context(), hall, m, room.Chat{Text: "你好"}); err != nil {
t.Fatal(err)
}
-140
View File
@@ -1,140 +0,0 @@
package api
import (
"context"
"errors"
"net/http"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/protocol"
)
// 用户在 GitHub 上授权之后,浏览器跳到这个路径。网页的 origin 加上 GitHubCallbackPath,就是 OAuth App 里登记的回调地址。
const GitHubCallbackPath = "/api/auth/github/callback"
// 回调处理完之后的落地页,结果放在片段里。App 用系统浏览器授权,要跳到 App 的自定义 scheme 才能回到 App。
// 自定义 scheme 可能被别的 App 抢注,但截获的兑换码换不到身份。兑换码 1 分钟内有效,只能用一次。兑换时要带上发起授权的客户端在本机保存的
// nonce,而且必须由发起授权的同一个成员调用。
const (
githubWebLanding = "/auth/github"
githubAppLanding = "moe.dihe.alisten:/auth/github"
)
func (s *server) startGithubLogin(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeStartGithubLoginRequest(c.body)
if err != nil {
return nil, err
}
intent := port.GitHubLogin
switch req.Intent {
case protocol.GitHubIntentBind:
intent = port.GitHubBind
case protocol.GitHubIntentVerify:
intent = port.GitHubVerify
}
client := port.GitHubWeb
if req.Client != nil && *req.Client == protocol.GitHubClientApp {
client = port.GitHubApp
}
url, err := s.GitHub.Authorize(ctx, c.member.ID, c.clientIP, intent, client, req.Nonce, deref(req.CurrentPassword))
if err != nil {
return nil, err
}
return protocol.StartGithubLoginResponse{URL: url}, nil
}
func (s *server) finishGithubLogin(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeFinishGithubLoginRequest(c.body)
if err != nil {
return nil, err
}
result, err := s.GitHub.Exchange(ctx, c.member, bearer(c.r), req.Code, req.Nonce)
if err != nil {
return nil, err
}
resp := protocol.FinishGithubLoginResponse{Member: memberDTO(result.Member), Account: accountDTO(result.Account)}
if result.Session != nil {
resp.MemberToken = &result.Session.MemberToken
}
return resp, nil
}
func (s *server) unbindGithub(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeUnbindGithubRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.GitHub.Unbind(ctx, c.member.ID, req.CurrentPassword)
}
func (s *server) adminGetGithubCredential(ctx context.Context, _ call) (any, error) {
clientID, err := s.GitHub.ClientID(ctx)
if err != nil {
return nil, err
}
return protocol.AdminGetGithubCredentialResponse{ClientID: optional(clientID), ClientSecretSet: clientID != "", CallbackURL: s.PublicURL + GitHubCallbackPath}, nil
}
func (s *server) adminSetGithubCredential(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminSetGithubCredentialRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.GitHub.SetOAuthApp(ctx, port.GitHubOAuthApp{ClientID: req.ClientID, ClientSecret: req.ClientSecret})
}
func (s *server) adminDeleteGithubCredential(ctx context.Context, _ call) (any, error) {
return nil, s.GitHub.DeleteOAuthApp(ctx)
}
// 浏览器从 GitHub 跳回来,换到兑换码之后跳到发起授权的客户端的落地页。结果放在片段里,不会出现在之后的请求和 Referer 里。
// 回调地址的查询串里有授权码,所以响应也不让浏览器把回调地址当作 Referer 发出去。跳转目标只看 state 对应的记录,不看回调地址里的参数,
// 否则就成了开放重定向。
func (s *server) githubCallback(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
defer cancel()
q := r.URL.Query()
var (
client port.GitHubClient
fragment string
err error
)
if reason := q.Get("error"); reason != "" {
fragment = "error=denied"
if reason != "access_denied" {
s.logger.WarnContext(ctx, "GitHub 拒绝了授权请求", "reason", reason, "description", q.Get("error_description"))
fragment = "error=failed"
}
client, err = s.GitHub.Cancel(ctx, q.Get("state"))
if err != nil && !errors.Is(err, identity.ErrGitHubRequestExpired) {
s.logger.ErrorContext(ctx, "作废 GitHub 授权的 state 失败", "error", err)
}
} else {
var code string
client, code, err = s.GitHub.Callback(ctx, q.Get("state"), q.Get("code"))
switch {
case err == nil:
fragment = "code=" + code
case errors.Is(err, identity.ErrGitHubRequestExpired):
fragment = "error=expired"
case errors.Is(err, identity.ErrGitHubDisabled):
fragment = "error=failed"
case errors.Is(err, identity.ErrGitHubFailed):
s.logger.WarnContext(ctx, "没有从 GitHub 换到用户", "error", err)
fragment = "error=failed"
default:
s.logger.ErrorContext(ctx, "处理 GitHub 的回调失败", "error", err)
fragment = "error=failed"
}
}
landing := githubWebLanding
if client == port.GitHubApp {
landing = githubAppLanding
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
// 不用 http.Redirect,因为 http.Redirect 会把片段当作路径的一部分清理掉。
w.Header().Set("Location", landing+"#"+fragment)
w.WriteHeader(http.StatusFound)
}
-3
View File
@@ -42,9 +42,6 @@ func (s *server) getMe(ctx context.Context, c call) (any, error) {
dto := accountDTO(account)
resp.Account = &dto
}
if resp.RecordListens, err = s.Listens.Recording(ctx, c.member.ID); err != nil {
return nil, err
}
return resp, nil
}
-42
View File
@@ -1,42 +0,0 @@
package api
import (
"context"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) listMyListens(ctx context.Context, c call) (any, error) {
listens, err := s.Listens.List(ctx, c.member.ID)
if err != nil {
return nil, err
}
items := make([]protocol.MyListen, len(listens))
for i, l := range listens {
items[i] = protocol.MyListen{Track: trackDTO(l.Track), Meta: s.trackMeta(l.Meta), HeardAtMs: l.HeardAt.UnixMilli()}
}
return protocol.ListMyListensResponse{Items: items}, nil
}
func (s *server) removeMyListen(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRemoveMyListenRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Listens.Remove(ctx, c.member.ID, trackOf(req.Track))
}
func (s *server) clearMyListens(ctx context.Context, c call) (any, error) {
return nil, s.Listens.Clear(ctx, c.member.ID)
}
func (s *server) enableListenRecording(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeEnableListenRecordingRequest(c.body); err != nil {
return nil, err
}
return nil, s.Listens.SetRecording(ctx, c.member.ID, true)
}
func (s *server) disableListenRecording(ctx context.Context, c call) (any, error) {
return nil, s.Listens.SetRecording(ctx, c.member.ID, false)
}
+15 -49
View File
@@ -3,9 +3,10 @@ package api
import (
"context"
"net/http"
"regexp"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/domain/track"
"github.com/dihemoe/alisten/protocol"
)
@@ -42,68 +43,33 @@ func (s *server) redirectTarget(ctx context.Context, r *http.Request, t track.Tr
return s.Playback.URL(ctx, t, time.Now())
}
var mediaSorts = map[protocol.MediaSort]port.MediaSort{
protocol.MediaSortStored: port.MediaByStored,
protocol.MediaSortIdle: port.MediaByIdle,
protocol.MediaSortSize: port.MediaBySize,
}
var mediaKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
func (s *server) adminListMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminListMediaRequest(c.body)
func (s *server) adminListMedia(ctx context.Context, _ call) (any, error) {
list, err := s.Media.List(ctx)
if err != nil {
return nil, err
}
q := port.MediaQuery{Page: 1, Sort: port.MediaByStored}
if req.Page != nil {
q.Page = int(*req.Page)
}
if req.Sort != nil {
q.Sort = mediaSorts[*req.Sort]
}
if req.Q != nil {
q.Title = *req.Q
}
page, err := s.Media.List(ctx, q)
if err != nil {
return nil, err
}
items := make([]protocol.StoredMedia, len(page.Items))
for i, m := range page.Items {
items := make([]protocol.StoredMedia, len(list))
for i, m := range list {
link := m.Link
// 后台把 link 当作链接打开,所以 B 站的歌给出视频页面。
// 后台把 link 当作链接打开,B 站的歌给出视频页面。
if m.Source == track.Bilibili {
link = track.BilibiliPageURL(m.Link)
}
items[i] = protocol.StoredMedia{
Key: m.Key,
Source: protocol.Source(m.Source),
Link: link,
Title: m.Title,
Artist: m.Artist,
CoverURL: s.coverURL(m.CoverURL, m.CoverKey),
Size: m.Size,
StoredAtMs: m.StoredAt.UnixMilli(),
}
items[i] = protocol.StoredMedia{Key: m.Key, Source: protocol.Source(m.Source), Link: link, Title: m.Title, Size: m.Size, StoredAtMs: m.StoredAt.UnixMilli()}
if m.LastPlayedAt != nil {
played := m.LastPlayedAt.UnixMilli()
items[i].LastPlayedAtMs = &played
}
}
return protocol.AdminListMediaResponse{Items: items, Total: int64(page.Total), TotalBytes: page.TotalBytes, Page: int64(q.Page), PageSize: port.MediaPageSize}, nil
return protocol.AdminListMediaResponse{Items: items}, nil
}
func (s *server) adminRemoveMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminRemoveMediaRequest(c.body)
if err != nil {
return nil, err
func (s *server) adminDeleteMedia(ctx context.Context, c call) (any, error) {
key := c.r.PathValue("key")
if !mediaKeyPattern.MatchString(key) {
return nil, media.ErrNotFound
}
keys := make([]string, len(req.Media))
for i, m := range req.Media {
keys[i] = m.Key
}
deleted, inUse, err := s.Media.DeleteMany(ctx, keys)
if err != nil {
return nil, err
}
return protocol.AdminRemoveMediaResponse{Deleted: deleted, InUse: inUse}, nil
return nil, s.Media.Delete(ctx, key)
}
+44 -44
View File
@@ -24,63 +24,63 @@ import (
"github.com/dihemoe/alisten/protocol"
)
type storedMedia struct {
items []port.StoredMedia
used bool
}
func (m *storedMedia) ListStoredMedia(context.Context) ([]port.StoredMedia, error) {
return m.items, nil
}
func (m *storedMedia) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
for _, item := range m.items {
if item.Key == key {
return item, true, nil
}
}
return port.StoredMedia{}, false, nil
}
func (m *storedMedia) DeleteStoredMedia(context.Context, string) error {
m.items = nil
return nil
}
func (m *storedMedia) TrackInUse(context.Context, track.Track) (bool, error) {
return m.used, nil
}
func TestAdminMedia(t *testing.T) {
key := media.Key("https://example.com/a.mp3")
other := media.Key("https://example.com/b.mp3")
video := media.Key("BV1xx411c7mD/2")
played := time.UnixMilli(1760000000456)
library := porttest.NewMediaLibrary(
port.StoredMedia{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Artist: "周杰伦", Size: 12290, CoverKey: track.ObjectKey("link/" + key + "/cover.jpg"), StoredAt: time.UnixMilli(1760000000123)},
port.StoredMedia{Key: other, Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, CoverURL: "https://example.com/b.jpg", StoredAt: time.UnixMilli(1760000000789), LastPlayedAt: &played},
port.StoredMedia{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Artist: "某个 UP 主", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
)
stored := &storedMedia{items: []port.StoredMedia{
{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Size: 12290, StoredAt: time.UnixMilli(1760000000123), LastPlayedAt: &played},
{Key: media.Key("https://example.com/b.mp3"), Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, StoredAt: time.UnixMilli(1760000000789)},
{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
}}
store := porttest.New()
s := services(store, store, "admin-secret")
s.StorageURL = "https://alisten.example/storage/alisten"
s.Media = media.NewLibrary(library, inRooms{{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}}, porttest.NewMediaStore())
s.Media = media.NewLibrary(stored, stored, porttest.NewMediaStore())
server, _ := start(t, s)
admin := []string{"Authorization", "Bearer admin-secret"}
want := response{status: http.StatusOK, body: `{"items":[` +
`{"artist":"某个 UP 主","coverUrl":"","key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"},` +
`{"artist":"","coverUrl":"https://example.com/b.jpg","key":"` + other + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
`{"artist":"周杰伦","coverUrl":"https://alisten.example/storage/alisten/link/` + key + `/cover.jpg","key":"` + key + `","lastPlayedAtMs":null,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"}],` +
`"page":1,"pageSize":50,"total":3,"totalBytes":12293}`}
if got := send(t, server, http.MethodPost, "/api/admin/media/queries", "{}", admin...); got != want {
t.Errorf("按默认参数列出已存储的媒体 = %#v\n期望 %#v", got, want)
}
for _, tt := range []struct {
body string
page int64
want []string
total, size int64
}{
{`{"sort": "stored"}`, 1, []string{video, other, key}, 3, 12293},
{`{"sort": "idle"}`, 1, []string{video, key, other}, 3, 12293},
{`{"sort": "size", "q": "晴天"}`, 1, []string{key, video}, 2, 12292},
{`{"page": 2}`, 2, []string{}, 3, 12293},
} {
got := send(t, server, http.MethodPost, "/api/admin/media/queries", tt.body, admin...)
var page protocol.AdminListMediaResponse
if err := json.Unmarshal([]byte(got.body), &page); err != nil {
t.Fatalf("%s = %#v", tt.body, got)
}
keys := make([]string, len(page.Items))
for i, m := range page.Items {
keys[i] = m.Key
}
if got.status != http.StatusOK || !slices.Equal(keys, tt.want) || page.Page != tt.page || page.Total != tt.total || page.TotalBytes != tt.size {
t.Errorf("%s = %#v,期望第 %d 页 %q,共 %d 首 %d 字节", tt.body, got, tt.page, tt.want, tt.total, tt.size)
}
`{"key":"` + key + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"},` +
`{"key":"` + media.Key("https://example.com/b.mp3") + `","lastPlayedAtMs":null,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
`{"key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"}]}`}
if got := send(t, server, http.MethodGet, "/api/admin/media", "", admin...); got != want {
t.Errorf("列出已存储的媒体 = %#v\n期望 %#v", got, want)
}
if got := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+key+`"}, {"key": "not-a-key"}]}`, admin...); got.status != http.StatusBadRequest {
t.Errorf("批量删除里有格式不对的 key = %#v,期望 400、一首也不删", got)
}
if got, want := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+video+`"}, {"key": "`+key+`"}, {"key": "`+media.Key("https://example.com/missing.mp3")+`"}]}`, admin...),
(response{status: http.StatusOK, body: `{"deleted":["` + key + `"],"inUse":["` + video + `"]}`}); got != want {
t.Errorf("批量删除 = %#v,期望 %#v", got, want)
stored.used = true
if got, want := send(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...), failure(protocol.ErrorCodeConflict, "这首歌还在房间的播放列表中,不能删除"); got != want {
t.Errorf("删除仍被房间引用的媒体 = %#v,期望 %#v", got, want)
}
stored.used = false
resp, header := exchange(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...)
checkNoContent(t, "删除媒体", resp, header)
}
type playURL map[string]string
-48
View File
@@ -1,48 +0,0 @@
package api
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"testing"
"time"
"rsc.io/qr"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/protocol"
)
type unlimited struct{}
func (unlimited) Take(context.Context, string, int, time.Duration) (time.Duration, error) {
return 0, nil
}
// 二维码的内容是网页上兑换配对码的地址,配对码放在片段里。
func TestPairingCodeQRImage(t *testing.T) {
store := porttest.New()
s := services(store, store, "")
s.Accounts = identity.NewAccounts(store, store, unlimited{}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), s.Gateway)
s.PublicURL = "https://alisten.example"
server, _ := start(t, s)
token := memberToken(t, server)
auth := []string{"Authorization", "Bearer " + token}
if got := send(t, server, http.MethodPost, "/api/account", `{"username":"alice","password":"correct horse"}`, auth...); got.status != http.StatusCreated {
t.Fatalf("注册 = %#v", got)
}
got := send(t, server, http.MethodPost, "/api/account/pairing-codes", "{}", auth...)
var created protocol.CreatePairingCodeResponse
if err := json.Unmarshal([]byte(got.body), &created); err != nil || got.status != http.StatusCreated {
t.Fatalf("生成配对码 = %#v", got)
}
code, err := qr.Encode("https://alisten.example/pair#"+created.Code, qr.M)
if err != nil {
t.Fatal(err)
}
if want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()); created.QrImage != want {
t.Errorf("二维码 = %.60q…,期望内容为 https://alisten.example/pair#%s 的 PNG", created.QrImage, created.Code)
}
}
+1 -97
View File
@@ -2,9 +2,7 @@ package api
import (
"context"
"time"
"github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
"github.com/dihemoe/alisten/protocol"
@@ -24,13 +22,12 @@ func (s *server) playlistDTO(p port.PlaylistRecord) protocol.MyPlaylist {
CreatedAtMs: p.CreatedAt.UnixMilli(),
UpdatedAtMs: p.UpdatedAt.UnixMilli(),
CoverURL: cover,
ShareID: optional(p.ShareID),
}
}
func (s *server) playlistItemDTO(item port.PlaylistItem) protocol.MyPlaylistItem {
return protocol.MyPlaylistItem{
Track: trackDTO(item.Track),
Track: protocol.Track{Source: protocol.Source(item.Track.Source), ID: item.Track.ID},
Meta: s.trackMeta(item.Meta),
}
}
@@ -147,96 +144,3 @@ func (s *server) moveMyPlaylistItems(ctx context.Context, c call) (any, error) {
}
return nil, s.Playlists.Move(ctx, c.member.ID, c.r.PathValue("id"), tracksOf(req.Tracks), before)
}
func (s *server) importMyPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeImportMyPlaylistRequest(c.body)
if err != nil {
return nil, err
}
tracks := make([]playlist.ImportedTrack, len(req.Tracks))
for i, t := range req.Tracks {
tracks[i] = playlist.ImportedTrack{
Track: trackOf(t.Track),
Title: t.Title,
Artist: deref(t.Artist),
Album: deref(t.Album),
CoverURL: deref(t.CoverURL),
PageURL: deref(t.PageURL),
Duration: time.Duration(t.DurationMs) * time.Millisecond,
}
}
p, n, err := s.Playlists.ImportFile(ctx, c.member.ID, req.Name, tracks)
if err != nil {
return nil, err
}
return protocol.ImportMyPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil
}
func (s *server) resolveMyPlaylistLink(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeResolveMyPlaylistLinkRequest(c.body)
if err != nil {
return nil, err
}
source, id, err := s.Playlists.ResolveLink(ctx, c.member.ID, req.Link)
if err != nil {
return nil, err
}
return protocol.ResolveMyPlaylistLinkResponse{Source: protocol.Source(source), PlaylistID: id}, nil
}
func (s *server) importMyPlatformPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeImportMyPlatformPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, n, err := s.Playlists.ImportPlatform(ctx, c.member.ID, req.Name, track.Source(req.Source), req.PlaylistID)
if err != nil {
return nil, err
}
return protocol.ImportMyPlatformPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil
}
func (s *server) shareMyPlaylist(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeShareMyPlaylistRequest(c.body); err != nil {
return nil, err
}
shareID, err := s.Playlists.Share(ctx, c.member.ID, c.r.PathValue("id"))
if err != nil {
return nil, err
}
return protocol.ShareMyPlaylistResponse{ShareID: shareID}, nil
}
func (s *server) unshareMyPlaylist(ctx context.Context, c call) (any, error) {
return nil, s.Playlists.Unshare(ctx, c.member.ID, c.r.PathValue("id"))
}
func (s *server) getSharedPlaylist(ctx context.Context, c call) (any, error) {
p, err := s.Playlists.Shared(ctx, c.r.PathValue("shareId"))
if err != nil {
return nil, err
}
items := make([]protocol.MyPlaylistItem, len(p.Items))
for i, item := range p.Items {
items[i] = s.playlistItemDTO(item)
}
return protocol.SharedPlaylist{
Name: p.Name,
Favorites: p.Favorites,
OwnerName: p.OwnerName,
OwnerAvatarHash: p.OwnerAvatarHash,
Items: items,
}, nil
}
func (s *server) copySharedPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeCopySharedPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, err := s.Playlists.CopyShared(ctx, c.member.ID, c.r.PathValue("shareId"), req.Name)
if err != nil {
return nil, err
}
return s.playlistDTO(p), nil
}
+3 -10
View File
@@ -87,22 +87,15 @@ func qrLogin(session port.QRLoginSession) (protocol.QRLogin, error) {
if session.LoginURL == "" {
return protocol.QRLogin{}, fmt.Errorf("音源没有给出二维码图片和登录链接:%w", port.ErrProviderFailed)
}
var err error
if image, err = qrDataURI(session.LoginURL); err != nil {
code, err := qr.Encode(session.LoginURL, qr.M)
if err != nil {
return protocol.QRLogin{}, fmt.Errorf("把登录链接编码成二维码:%w:%w", err, port.ErrProviderFailed)
}
image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())
}
return protocol.QRLogin{Key: session.Key, QrImage: image, ExpiresAtMs: session.ExpiresAt.UnixMilli()}, nil
}
func qrDataURI(text string) (string, error) {
code, err := qr.Encode(text, qr.M)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()), nil
}
func qrLoginResult(r port.QRLoginResult) protocol.QRLoginResult {
return protocol.QRLoginResult{Status: protocol.QRLoginStatus(r.Status), Message: r.Message}
}
+3 -3
View File
@@ -43,7 +43,7 @@ func newRoomFixture(t *testing.T) roomFixture {
store := porttest.New()
music := newCatalog(store, 10)
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
@@ -232,7 +232,7 @@ func TestRoomRequestOfDisconnectedClientIsNotCounted(t *testing.T) {
func TestRoomListingShowsOnlineCount(t *testing.T) {
f := newRoomFixture(t)
id, m, _ := f.enter(t)
session, err := f.gateway.Join(t.Context(), id, m, nil, nil)
session, err := f.gateway.Join(t.Context(), id, m, nil)
if err != nil {
t.Fatal(err)
}
@@ -252,7 +252,7 @@ func TestProfileChangeReachesRooms(t *testing.T) {
session := decode[protocol.CreateMemberResponse](t, send(t, f.server, http.MethodPost, "/api/members", "{}"))
created := decode[protocol.CreateRoomResponse](t, send(t, f.server, http.MethodPost, "/api/rooms", `{"name":"临时","description":""}`))
m := member.Member{ID: session.Member.ID, Name: session.Member.Name}
s, err := f.gateway.Join(t.Context(), created.ID, m, nil, nil)
s, err := f.gateway.Join(t.Context(), created.ID, m, nil)
if err != nil {
t.Fatal(err)
}
+1 -5
View File
@@ -11,11 +11,7 @@ func (s *server) getSite(ctx context.Context, _ call) (any, error) {
if err != nil {
return nil, err
}
githubClientID, err := s.GitHub.ClientID(ctx)
if err != nil {
return nil, err
}
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen, GithubLogin: githubClientID != ""}, nil
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen}, nil
}
func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
+1 -1
View File
@@ -16,7 +16,7 @@ func TestSite(t *testing.T) {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `"}`}
}
site := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","githubLogin":false,"registrationOpen":true}`}
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","registrationOpen":true}`}
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("Alisten") {
+4 -4
View File
@@ -71,8 +71,8 @@ func (s *server) websocket(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 超时时先以 4408 关闭连接,再取消 ctx。如果先取消 ctx,正在读取第一帧的 Read 会直接断开连接,
// 客户端就收不到 4408。
// 超时时先以 4408 关闭连接,再取消 ctx:如果先取消,正在读取第一帧的 Read 会直接断开连接,
// 客户端收不到 4408。
timeout := time.AfterFunc(helloTimeout, func() {
_ = conn.Close(protocol.CloseHandshakeTimeout, "")
cancel()
@@ -197,7 +197,7 @@ func (s *server) perform(ctx context.Context, sess *realtime.Session, tag reques
}
return err
}
// 握手时已经拒绝了 API Key,所以会话的成员不是 bot,房间命令总是以会话的成员发出。
// 握手拒绝了 API Key,会话的成员不是 bot,房间命令总以会话的成员发出。
a, _, err := s.action(ctx, sess.Room(), sess.Member(), false, data)
if err != nil {
sess.Fail(tag, err)
@@ -216,7 +216,7 @@ func (s *server) write(ctx context.Context, conn *websocket.Conn, sess *realtime
_ = conn.Close(sessionCloseCodes[reason], "")
return
}
// 写入以会话的 ctx 为期限。这样客户端不再读取、发送队列随之溢出时,阻塞的写入会随会话关闭而中止。
// 写入以会话的 ctx 为期限:客户端不再读取、发送队列溢出时,阻塞的写入随会话关闭而中止。
if !s.writeMessage(sess.Context(), conn, s.message(m, helloID)) {
return
}
+4 -4
View File
@@ -36,7 +36,7 @@ import (
"github.com/dihemoe/alisten/protocol"
)
// 内存里的监听器。synctest 的时钟只在 bubble 内的全部 goroutine 都持久阻塞时才前进,而阻塞在真实网络连接上不算持久阻塞。
// 内存中的监听器:synctest 的时钟只在 bubble 内的全部 goroutine 都持久阻塞时前进,阻塞在真实网络连接上不算持久阻塞。
type pipeListener struct {
conns chan net.Conn
closed chan struct{}
@@ -118,7 +118,7 @@ type wsFixture struct {
listener *pipeListener
metrics *prometheus.Registry
spans *tracetest.SpanRecorder
// 这些场景都不应该产生日志。需要日志的用例读取 logs 之后要清空 logs。
// 这些场景都不应产生日志;需要日志的用例读取之后要清空它。
logs *bytes.Buffer
}
@@ -134,7 +134,7 @@ func newWSFixtureWithTickets(t *testing.T, store *porttest.Store, tickets port.T
music := newCatalog(directory, 10)
node := porttest.NewRedis().Node("a")
preparer := porttest.NewPreparer()
host, gateway := realtime.New(snapshots, store, store, store, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(snapshots, store, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
@@ -316,7 +316,7 @@ func TestHandshakeTimeoutCancelsLookups(t *testing.T) {
store := porttest.New()
directory := &stallingDirectory{Store: store, returned: make(chan struct{})}
f := newWSFixture(t, store, store, directory)
// 关闭连接时同步检查查询有没有被取消,这样结果不取决于 goroutine 的调度。
// 关闭连接时同步检查查询是否已被取消,结果不取决于 goroutine 的调度。
var cancelledFirst atomic.Bool
f.listener.onServerClose = func() {
if ctx := directory.lookup.Load(); ctx != nil && (*ctx).Err() != nil {
+5 -5
View File
@@ -25,8 +25,8 @@ const (
prepareQueue = "prepare"
prepareWorkers = 4
prepareTimeout = 15 * time.Minute
// 进程崩溃后停留在 running 状态的任务,最多过这么久就由 River 重新调度。River 默认至少要等 1 小时,
// 这期间这首歌一直处于准备中。
// 进程崩溃后停留在 running 状态的任务,最多这么久之后由 River 重新调度;River 默认至少要等 1 小时,
// 期间这首歌一直处于准备中。
rescueStuckJobs = 20 * time.Minute
)
@@ -37,7 +37,7 @@ const (
mediaWorkers = 1
)
// 适配器之间不能互相 import,所以 internal/adapter/out/queue 要按同样的 kind 和字段提交任务。
// 适配器之间不能互相 import,internal/adapter/out/queue 按同样的 kind 和字段提交任务。
type prepareArgs struct {
Source string `json:"source"`
ID string `json:"id"`
@@ -81,7 +81,7 @@ func newClient(pool *pgxpool.Pool, queues map[string]river.QueueConfig, run func
Workers: workers,
JobTimeout: prepareTimeout,
RescueStuckJobsAfter: rescueStuckJobs,
// 任务在单独的 trace 里执行,只链接到提交任务的 span,因为任务可能很久之后才执行,失败后还会重试,同一首歌的任务也由所有等待这首歌的房间共用。
// 任务在单独的 trace 中执行,只链接到提交它的 span:任务可能很久之后才执行,失败后还会重试,同一首歌的任务也由所有等待它的房间共用。
Plugins: []rivertype.Plugin{otelriver.NewMiddleware(&otelriver.MiddlewareConfig{TracerProvider: tracer, EnableTracePropagation: true})},
// River 在 info 级别记录每次启停和每个出错的任务,出错的任务由 Watch 记录。
// 放进 river 分组,River 自己的属性(如 job_id)才不会与日志的关联字段重名。
@@ -136,7 +136,7 @@ func Watch(client *river.Client[pgx.Tx], finished func(port.PrepareOutcome), obs
}
}
// River 记录取消的原因时带有 "JobCancelError: " 前缀。internal/adapter/out/queue 用同样的方式提取任务失败的原因。
// River 记录取消的原因时带有 "JobCancelError: " 前缀;internal/adapter/out/queue 以同样的方式提取任务失败的原因。
func reason(job *rivertype.JobRow) string {
if len(job.Errors) == 0 {
return "准备任务失败"
+5 -5
View File
@@ -38,7 +38,7 @@ func (t *Transcoder) ToMP3(ctx context.Context, audio port.LinkFile) (port.LinkF
return nil, nil, fmt.Errorf("创建临时目录:%w", err)
}
release := func() { _ = os.RemoveAll(dir) }
// 用 defer 是为了 panic 时也删除目录,因为 River 会恢复任务里的 panic,worker 还会继续运行。
// 用 defer 是为了 panic 时也删除目录:River 会恢复任务中的 panic,worker 继续运行。
done := false
defer func() {
if !done {
@@ -54,7 +54,7 @@ func (t *Transcoder) ToMP3(ctx context.Context, audio port.LinkFile) (port.LinkF
}
func (t *Transcoder) transcode(ctx context.Context, audio port.LinkFile, dir string) (port.LinkFile, error) {
// 先复制成文件再交给 ffmpeg,因为 MP4 的 moov 可能在文件末尾,从管道读不了。文件名不带扩展名,这样 ffmpeg 只能按内容识别格式。
// 先复制成文件再交给 ffmpeg:MP4 的 moov 可能在文件末尾,从管道读不了。文件名不带扩展名,ffmpeg 只能按内容识别格式。
input := filepath.Join(dir, "input")
switch err := copyFile(input, audio); {
case errors.Is(err, port.ErrAudioTooLarge):
@@ -65,8 +65,8 @@ func (t *Transcoder) transcode(ctx context.Context, audio port.LinkFile, dir str
output := filepath.Join(dir, "audio.mp3")
stderr, err := run(ctx, args(input, output))
if err == nil {
// 转码成功后立即删除输入副本,不等 release,因为源音频和 MP3 在上传期间还占着临时空间,
// 同一容器还可能在并行准备其他链接。
// 转码成功后立即删除输入副本,不等 release:源音频和 MP3 在上传期间仍占用临时空间,
// 同一容器还可能并行准备其他链接。
_ = os.Remove(input)
return file(output), nil
}
@@ -119,7 +119,7 @@ func args(input, output string) []string {
"-map_metadata", "-1", "-map_chapters", "-1",
// 与 yt-dlp 的 --audio-format mp3 默认质量一致。
"-c:a", "libmp3lame", "-q:a", "5",
// ffmpeg 写到不少于 -fs 的字节数就停止,并且正常退出。-fs 比上限多一个字节,截断的输出才一定超过上限,进而被准备流程拒绝。
// ffmpeg 写到不少于 -fs 的字节数就停止并正常退出。多一个字节,截断的输出才一定超过上限,由准备流程拒绝。
"-fs", strconv.Itoa(port.MaxAudioSize + 1),
output,
}
+7 -7
View File
@@ -194,7 +194,7 @@ func TestToMP3(t *testing.T) {
}
}
// 第二条音轨是声道更多的默认音轨,不指定映射时 ffmpeg 会选这一条。
// 第二条音轨是声道更多的默认音轨:不指定映射时 ffmpeg 会选它。
func TestToMP3UsesFirstAudioTrack(t *testing.T) {
input := generate(t, "two.mp4", slices.Concat(sine, []string{
"-f", "lavfi", "-i", "sine=frequency=880:duration=2", "-map", "0:a", "-map", "1:a", "-c:a", "aac",
@@ -219,7 +219,7 @@ func TestToMP3DropsSourceChapters(t *testing.T) {
}
}
// 和 yt-dlp 的默认质量一样用 VBR。ffmpeg 给码率变化的 mp3 写 Xing 头,固定码率时写 Info 头。
// 与 yt-dlp 的默认质量一样用 VBR:ffmpeg 给码率变化的 mp3 写 Xing 头,固定码率时写 Info 头。
func TestToMP3EncodesVBR(t *testing.T) {
input := generate(t, "audio.m4a", slices.Concat(sine, []string{"-c:a", "aac"})...)
mp3, release, err := New(t.TempDir(), discard).ToMP3(t.Context(), source(t, input))
@@ -258,7 +258,7 @@ func TestToMP3RejectsInputOverLimit(t *testing.T) {
rejected bool
logged int
}{
// 内容全是零,复制完之后 ffmpeg 认不出格式,所以转码失败,但不是因为超过上限。
// 内容全是零,复制完之后 ffmpeg 认不出格式:转码失败,但不是因为超过上限。
{port.MaxAudioSize, false, 1},
// 超过上限时不运行 ffmpeg,也就没有转码失败的日志。
{port.MaxAudioSize + 1, true, 0},
@@ -282,7 +282,7 @@ func TestToMP3RejectsInputOverLimit(t *testing.T) {
}
}
// 把 -fs 换成很小的值,看真实的 ffmpeg 怎样处理。期望截断时正常退出,输出不少于 -fs 的字节数。
// 把 -fs 换成很小的值,看真实的 ffmpeg 怎样处理:截断时正常退出,输出不少于 -fs 的字节数。
func TestOutputStopsAtFileSizeLimit(t *testing.T) {
input := generate(t, "long.m4a", "-f", "lavfi", "-i", "sine=frequency=440:duration=10", "-c:a", "aac")
output := filepath.Join(t.TempDir(), "audio.mp3")
@@ -326,8 +326,8 @@ func listenLocal(t *testing.T) (address string, connected func() bool) {
}
}
// 输入复制成了不带扩展名的文件,ffmpeg 不会把它当 HLS 播放列表,所以 HLS 一行走不到打开分片的一步。DASH 清单按内容识别,会走到那一步,然后被白名单拒绝。
// ffmpeg 读本地文件时默认的白名单是 file,crypto,data。stderr 里写的是 'file',说明命令行上的 -protocol_whitelist file 起了作用。
// 输入复制成了不带扩展名的文件,ffmpeg 不会把它当 HLS 播放列表,所以 HLS 一行走不到打开分片的一步;DASH 清单按内容识别,会走到那一步,被白名单拒绝。
// ffmpeg 读本地文件时默认的白名单是 file,crypto,data:stderr 里写着 'file',说明命令行上的 -protocol_whitelist file 起了作用。
func TestToMP3DoesNotUseNetwork(t *testing.T) {
tests := []struct {
name string
@@ -348,7 +348,7 @@ func TestToMP3DoesNotUseNetwork(t *testing.T) {
address, connected := listenLocal(t)
root := t.TempDir()
logger, records := recorder()
// 地址如果被访问,ffmpeg 会一直等响应,所以限定期限,免得挂住测试。
// 地址如果被访问,ffmpeg 会一直等响应:限定期限,免得挂住测试。
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
_, release, err := New(root, logger).ToMP3(ctx, porttest.LinkFile(fmt.Sprintf(tt.manifest, address)))
-139
View File
@@ -1,139 +0,0 @@
package github
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.GitHubOAuth = (*OAuth)(nil)
const (
timeout = 10 * time.Second
// GitHub 的这两个响应都只有几百字节。
maxResponseBytes = 64 << 10
)
type Endpoint struct {
AuthorizeURL string
TokenURL string
UserURL string
}
var GitHubCom = Endpoint{
AuthorizeURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
UserURL: "https://api.github.com/user",
}
type Config struct {
RedirectURL string
Endpoint Endpoint
}
type OAuth struct {
client *http.Client
cfg Config
}
func New(client *http.Client, cfg Config) *OAuth {
return &OAuth{client: client, cfg: cfg}
}
func (o *OAuth) AuthorizeURL(app port.GitHubOAuthApp, state, verifier string) string {
challenge := sha256.Sum256([]byte(verifier))
q := url.Values{
"client_id": {app.ClientID},
"redirect_uri": {o.cfg.RedirectURL},
"state": {state},
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
"code_challenge_method": {"S256"},
}
return o.cfg.Endpoint.AuthorizeURL + "?" + q.Encode()
}
func (o *OAuth) UserID(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
token, err := o.token(ctx, app, code, verifier)
if err != nil {
return 0, fmt.Errorf("向 GitHub 换取令牌:%w", err)
}
id, err := o.user(ctx, token)
if err != nil {
return 0, fmt.Errorf("读取 GitHub 用户:%w", err)
}
return id, nil
}
func (o *OAuth) token(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (string, error) {
form := url.Values{"client_id": {app.ClientID}, "client_secret": {app.ClientSecret}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {o.cfg.RedirectURL}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.cfg.Endpoint.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
// 授权码错误或过期时,GitHub 也以 200 返回,错误放在响应体的 error 中。
var body struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := o.do(req, &body); err != nil {
return "", err
}
if body.Error != "" {
return "", fmt.Errorf("GitHub 拒绝了授权码:%s(%s)", body.Error, body.ErrorDescription)
}
if body.AccessToken == "" {
return "", fmt.Errorf("GitHub 没有给出令牌")
}
return body.AccessToken, nil
}
func (o *OAuth) user(ctx context.Context, token string) (int64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, o.cfg.Endpoint.UserURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
// GitHub 的 REST API 拒绝不带 User-Agent 的请求。
req.Header.Set("User-Agent", "alisten")
var body struct {
ID int64 `json:"id"`
}
if err := o.do(req, &body); err != nil {
return 0, err
}
if body.ID <= 0 {
return 0, fmt.Errorf("GitHub 返回的用户 ID 不是正数:%d", body.ID)
}
return body.ID, nil
}
func (o *OAuth) do(req *http.Request, v any) error {
resp, err := o.client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub 返回 HTTP %d", resp.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(v); err != nil {
return fmt.Errorf("解码 GitHub 的响应:%w", err)
}
return nil
}
-170
View File
@@ -1,170 +0,0 @@
package github
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
const (
verifier = "dBjftJeZ4CVP-mJ92K1D5gV9dxWXJyqi5aZoxwYkrUM"
challenge = "kXQYIXxOaWujAPbjqX_yBdiwMfNi4RzDNMDwDNpE7QQ"
)
var app = port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}
// 假的 GitHub:授权码 good 签发时绑定了 challenge,带上 verifier 才能换到令牌 token-1,令牌 token-1 对应用户 42。
type fakeGitHub struct {
token func(w http.ResponseWriter, r *http.Request)
user func(w http.ResponseWriter, r *http.Request)
}
func newFake(t *testing.T) (*fakeGitHub, Config) {
t.Helper()
fake := &fakeGitHub{}
fake.token = func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Error(err)
}
if r.Method != http.MethodPost || r.Header.Get("Accept") != "application/json" {
t.Errorf("换取令牌的请求 = %s,Accept: %q", r.Method, r.Header.Get("Accept"))
}
want := url.Values{"client_id": {"client-1"}, "client_secret": {"secret-1"}, "code": {r.PostForm.Get("code")}, "code_verifier": {verifier}, "redirect_uri": {"https://alisten.example/api/auth/github/callback"}}
if r.PostForm.Encode() != want.Encode() {
t.Errorf("换取令牌的参数 = %s,期望 %s", r.PostForm.Encode(), want.Encode())
}
w.Header().Set("Content-Type", "application/json")
if r.PostForm.Get("code") == "good" {
_, _ = w.Write([]byte(`{"access_token":"token-1","token_type":"bearer","scope":""}`))
return
}
_, _ = w.Write([]byte(`{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired.","error_uri":"/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code"}`))
}
fake.user = func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer token-1" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"Bad credentials"}`))
return
}
if r.Header.Get("User-Agent") == "" || r.Header.Get("Accept") != "application/vnd.github+json" {
t.Errorf("用户接口的请求头 User-Agent: %q,Accept: %q", r.Header.Get("User-Agent"), r.Header.Get("Accept"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"octocat","id":42,"name":"The Octocat"}`))
}
mux := http.NewServeMux()
mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { fake.token(w, r) })
mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { fake.user(w, r) })
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return fake, Config{
RedirectURL: "https://alisten.example/api/auth/github/callback",
Endpoint: Endpoint{
AuthorizeURL: server.URL + "/login/oauth/authorize",
TokenURL: server.URL + "/login/oauth/access_token",
UserURL: server.URL + "/user",
},
}
}
func TestAuthorizeURL(t *testing.T) {
o := New(http.DefaultClient, Config{RedirectURL: "https://alisten.example/api/auth/github/callback", Endpoint: GitHubCom})
u, err := url.Parse(o.AuthorizeURL(app, "state 1&2", verifier))
if err != nil {
t.Fatal(err)
}
if got := u.Scheme + "://" + u.Host + u.Path; got != "https://github.com/login/oauth/authorize" {
t.Errorf("授权地址 = %s", got)
}
want := url.Values{
"client_id": {"client-1"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"redirect_uri": {"https://alisten.example/api/auth/github/callback"},
"state": {"state 1&2"},
}
if got := u.Query(); got.Encode() != want.Encode() {
t.Errorf("授权地址的参数 = %s,期望 %s:不申请任何 scope,只读公开资料", got.Encode(), want.Encode())
}
}
func TestUserID(t *testing.T) {
_, cfg := newFake(t)
o := New(http.DefaultClient, cfg)
if id, err := o.UserID(t.Context(), app, "good", verifier); err != nil || id != 42 {
t.Errorf("UserID = %d, %v,期望 42", id, err)
}
}
func TestUserIDFails(t *testing.T) {
for _, tc := range []struct {
name string
code string
token func(w http.ResponseWriter, r *http.Request)
user func(w http.ResponseWriter, r *http.Request)
want string
}{
{name: "授权码错误或过期", code: "expired", want: "bad_verification_code"},
{name: "令牌接口返回非 200", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}, want: "503"},
{name: "令牌接口返回的不是 JSON", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`access_token=token-1&token_type=bearer`))
}, want: "解码"},
{name: "令牌为空", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"access_token":""}`))
}, want: "没有给出令牌"},
{name: "用户接口拒绝令牌", code: "good", user: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}, want: "401"},
{name: "用户 ID 不是正数", code: "good", user: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"login":"ghost"}`))
}, want: "用户 ID"},
} {
t.Run(tc.name, func(t *testing.T) {
fake, cfg := newFake(t)
if tc.token != nil {
fake.token = tc.token
}
if tc.user != nil {
fake.user = tc.user
}
_, err := New(http.DefaultClient, cfg).UserID(t.Context(), app, tc.code, verifier)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("UserID 的错误 = %v,期望包含 %q", err, tc.want)
}
if err != nil && strings.Contains(err.Error(), "secret-1") {
t.Errorf("错误信息中不能有 client secret:%v", err)
}
})
}
}
// 不响应的 GitHub:请求一直挂着,直到请求的 ctx 结束。
type hangingTransport struct{}
func (hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
}
func TestUserIDTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
o := New(&http.Client{Transport: hangingTransport{}}, Config{Endpoint: GitHubCom})
start := time.Now()
if _, err := o.UserID(t.Context(), app, "good", verifier); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("GitHub 不响应时的错误 = %v,期望超过期限", err)
}
if elapsed := time.Since(start); elapsed != 10*time.Second {
t.Errorf("等了 %v 才中止,期望 10 秒", elapsed)
}
})
}
@@ -120,7 +120,7 @@ func TestConcurrentFirstResetsCreateOneBot(t *testing.T) {
store, pool := newStore(t)
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
// 先锁住房间行,让两次首次生成都等这把锁,放开之后再同时进行。
// 先锁住房间行,两个首次生成都在等它,放开之后同时进行。
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
@@ -1,57 +0,0 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.GitHubOAuthApps = (*Store)(nil)
const githubCredential = "github"
// Client ID 和 Client Secret 存在凭据表的同一行里,一起写入、一起删除。
type githubOAuthApp struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
func (s *Store) GitHubOAuthApp(ctx context.Context) (port.GitHubOAuthApp, error) {
value, err := s.q.Credential(ctx, githubCredential)
if errors.Is(err, pgx.ErrNoRows) {
return port.GitHubOAuthApp{}, nil
}
if err != nil {
return port.GitHubOAuthApp{}, fmt.Errorf("读取 GitHub OAuth App:%w", err)
}
var app githubOAuthApp
// 不包装解码的错误,因为解码的错误里可能带有原值的片段,也就可能带有 Client Secret。
if err := json.Unmarshal([]byte(value), &app); err != nil || app.ClientID == "" || app.ClientSecret == "" {
return port.GitHubOAuthApp{}, errors.New("凭据表中 GitHub OAuth App 的格式不正确")
}
return port.GitHubOAuthApp(app), nil
}
func (s *Store) SetGitHubOAuthApp(ctx context.Context, app port.GitHubOAuthApp) error {
value, err := json.Marshal(githubOAuthApp(app))
if err != nil {
return fmt.Errorf("编码 GitHub OAuth App:%w", err)
}
if err := s.q.SetCredential(ctx, query.SetCredentialParams{Name: githubCredential, Value: string(value)}); err != nil {
return fmt.Errorf("保存 GitHub OAuth App:%w", err)
}
return nil
}
func (s *Store) DeleteGitHubOAuthApp(ctx context.Context) error {
if err := s.q.DeleteCredential(ctx, githubCredential); err != nil {
return fmt.Errorf("清除 GitHub OAuth App:%w", err)
}
return nil
}
@@ -1,91 +0,0 @@
package postgres
import (
"fmt"
"strings"
"sync"
"testing"
"github.com/dihemoe/alisten/internal/app/port"
)
func TestGitHubOAuthApp(t *testing.T) {
store, pool := newStore(t)
ctx := t.Context()
if app, err := store.GitHubOAuthApp(ctx); err != nil || app != (port.GitHubOAuthApp{}) {
t.Fatalf("没有配置时 = %#v, %v,期望零值", app, err)
}
if err := store.SetNeteaseCookie(ctx, "MUSIC_U=1"); err != nil {
t.Fatal(err)
}
for _, want := range []port.GitHubOAuthApp{{ClientID: "client-1", ClientSecret: "secret-1"}, {ClientID: "client-2", ClientSecret: "secret-2"}} {
if err := store.SetGitHubOAuthApp(ctx, want); err != nil {
t.Fatal(err)
}
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != want {
t.Errorf("GitHubOAuthApp = %#v, %v,期望 %#v", got, err, want)
}
}
// 并发地保存和读取。读到的 Client ID 和 Secret 总是同一次保存的。
var wg sync.WaitGroup
for i := range 4 {
wg.Go(func() {
for j := range 20 {
app := port.GitHubOAuthApp{ClientID: fmt.Sprintf("client-%d-%d", i, j), ClientSecret: fmt.Sprintf("secret-%d-%d", i, j)}
if err := store.SetGitHubOAuthApp(ctx, app); err != nil {
t.Error(err)
return
}
}
})
wg.Go(func() {
for range 20 {
got, err := store.GitHubOAuthApp(ctx)
if err != nil {
t.Error(err)
return
}
if strings.TrimPrefix(got.ClientID, "client-") != strings.TrimPrefix(got.ClientSecret, "secret-") {
t.Errorf("读到的 Client ID 和 Secret 不是同一次保存的:%#v", got)
}
}
})
}
wg.Wait()
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
t.Fatal(err)
}
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != (port.GitHubOAuthApp{}) {
t.Errorf("清除之后 = %#v, %v,期望零值", got, err)
}
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
t.Errorf("没有配置时清除 = %v,期望什么也不做", err)
}
var rows int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM credentials").Scan(&rows); err != nil {
t.Fatal(err)
}
if cookie, err := store.NeteaseCookie(ctx); err != nil || cookie != "MUSIC_U=1" || rows != 1 {
t.Errorf("清除之后网易云 Cookie = %q, %v,凭据表有 %d 行,期望只删除 GitHub 的一行", cookie, err, rows)
}
}
// 存储里的值不是保存时的格式时报错,错误信息里不带原值,因为原值里可能有 Client Secret。
func TestGitHubOAuthAppRejectsCorruptValue(t *testing.T) {
store, pool := newStore(t)
for _, value := range []string{`not json secret-1`, `{"clientId":"client-1"}`, `{"clientSecret":"secret-1"}`} {
if _, err := pool.Exec(t.Context(), "INSERT INTO credentials (name, value) VALUES ('github', $1) ON CONFLICT (name) DO UPDATE SET value = excluded.value", value); err != nil {
t.Fatal(err)
}
_, err := store.GitHubOAuthApp(t.Context())
if err == nil {
t.Errorf("存储的值为 %q 时应报错", value)
continue
}
if strings.Contains(err.Error(), "secret-1") {
t.Errorf("错误信息中有原值:%v", err)
}
}
}
@@ -40,8 +40,8 @@ const lockRoomBot = `-- name: LockRoomBot :one
SELECT bot_member_id FROM rooms WHERE id = $1 FOR NO KEY UPDATE
`
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE,因为同一个房间的重置要依次进行。
// 这样同时到达的首次生成只会创建一个 bot 成员,最后只留下一把 Key。
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个房间的重置要依次进行,
// 同时到达的首次生成才只会创建一个 bot 成员,最后只留下一把 Key。
func (q *Queries) LockRoomBot(ctx context.Context, id string) (*string, error) {
row := q.db.QueryRow(ctx, lockRoomBot, id)
var bot_member_id *string
@@ -9,48 +9,6 @@ import (
"context"
)
// iteratorForInsertPlaylistItems implements pgx.CopyFromSource.
type iteratorForInsertPlaylistItems struct {
rows []InsertPlaylistItemsParams
skippedFirstNextCall bool
}
func (r *iteratorForInsertPlaylistItems) Next() bool {
if len(r.rows) == 0 {
return false
}
if !r.skippedFirstNextCall {
r.skippedFirstNextCall = true
return true
}
r.rows = r.rows[1:]
return len(r.rows) > 0
}
func (r iteratorForInsertPlaylistItems) Values() ([]interface{}, error) {
return []interface{}{
r.rows[0].PlaylistID,
r.rows[0].Source,
r.rows[0].TrackID,
r.rows[0].Title,
r.rows[0].Artist,
r.rows[0].Album,
r.rows[0].CoverURL,
r.rows[0].CoverKey,
r.rows[0].DurationMs,
r.rows[0].PageURL,
r.rows[0].Position,
}, nil
}
func (r iteratorForInsertPlaylistItems) Err() error {
return nil
}
func (q *Queries) InsertPlaylistItems(ctx context.Context, arg []InsertPlaylistItemsParams) (int64, error) {
return q.db.CopyFrom(ctx, []string{"playlist_items"}, []string{"playlist_id", "source", "track_id", "title", "artist", "album", "cover_url", "cover_key", "duration_ms", "page_url", "position"}, &iteratorForInsertPlaylistItems{rows: arg})
}
// iteratorForInsertPlays implements pgx.CopyFromSource.
type iteratorForInsertPlays struct {
rows []InsertPlaysParams
@@ -93,7 +93,7 @@ type AutoDJSeedsParams struct {
IncludeQueue bool
}
// 只看最近 1000 次播放。查询按 plays (room_id, started_at) 的索引倒序读,读到 1000 行就停,所以耗时与历史的长度无关。
// 只看最近 1000 次播放:按 plays (room_id, started_at) 的索引倒序读到 1000 行就停,耗时与历史的长度无关。
func (q *Queries) AutoDJSeeds(ctx context.Context, arg AutoDJSeedsParams) ([]string, error) {
rows, err := q.db.Query(ctx, autoDJSeeds, arg.RoomID, arg.IncludeQueue)
if err != nil {
@@ -1,234 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: listens.sql
package query
import (
"context"
"time"
)
const clearListens = `-- name: ClearListens :exec
DELETE FROM listens WHERE member_id = $1
`
func (q *Queries) ClearListens(ctx context.Context, memberID string) error {
_, err := q.db.Exec(ctx, clearListens, memberID)
return err
}
const deleteListen = `-- name: DeleteListen :exec
DELETE FROM listens WHERE member_id = $1 AND source = $2 AND track_id = $3
`
type DeleteListenParams struct {
MemberID string
Source string
TrackID string
}
func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) error {
_, err := q.db.Exec(ctx, deleteListen, arg.MemberID, arg.Source, arg.TrackID)
return err
}
const listenRecording = `-- name: ListenRecording :one
SELECT record_listens FROM members WHERE id = $1
`
func (q *Queries) ListenRecording(ctx context.Context, id string) (bool, error) {
row := q.db.QueryRow(ctx, listenRecording, id)
var record_listens bool
err := row.Scan(&record_listens)
return record_listens, err
}
const lockListeners = `-- name: LockListeners :many
SELECT id FROM members WHERE id = ANY($1::uuid[]) AND record_listens ORDER BY id FOR NO KEY UPDATE
`
// 按 ID 的顺序锁住开启了记录的成员。这样同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删。
// 加锁的顺序固定,所以同时写几个成员的事务之间不会死锁。
func (q *Queries) LockListeners(ctx context.Context, ids []string) ([]string, error) {
rows, err := q.db.Query(ctx, lockListeners, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const memberListens = `-- name: MemberListens :many
SELECT source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM listens WHERE member_id = $1
ORDER BY heard_at DESC, source, track_id
LIMIT $2
`
type MemberListensParams struct {
MemberID string
Max int32
}
type MemberListensRow struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
HeardAt time.Time
}
func (q *Queries) MemberListens(ctx context.Context, arg MemberListensParams) ([]MemberListensRow, error) {
rows, err := q.db.Query(ctx, memberListens, arg.MemberID, arg.Max)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MemberListensRow
for rows.Next() {
var i MemberListensRow
if err := rows.Scan(
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.DurationMs,
&i.PageURL,
&i.HeardAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveListens = `-- name: MoveListens :exec
WITH moved AS (
DELETE FROM listens WHERE member_id = $2::uuid
RETURNING source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
)
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT $1::uuid, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM moved
WHERE EXISTS (SELECT FROM members WHERE id = $1::uuid AND record_listens)
ON CONFLICT (member_id, 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, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at)
`
type MoveListensParams struct {
ToID string
FromID string
}
// 并入的规则与 UpsertListens 相同,并入的那份算新写入的。to_id 关闭了记录时只删不并。
func (q *Queries) MoveListens(ctx context.Context, arg MoveListensParams) error {
_, err := q.db.Exec(ctx, moveListens, arg.ToID, arg.FromID)
return err
}
const setListenRecording = `-- name: SetListenRecording :execrows
UPDATE members SET record_listens = $1 WHERE id = $2
`
type SetListenRecordingParams struct {
RecordListens bool
ID string
}
func (q *Queries) SetListenRecording(ctx context.Context, arg SetListenRecordingParams) (int64, error) {
result, err := q.db.Exec(ctx, setListenRecording, arg.RecordListens, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const trimListens = `-- name: TrimListens :exec
DELETE FROM listens l
USING (
SELECT member_id, source, track_id,
row_number() OVER (PARTITION BY member_id ORDER BY heard_at DESC, source, track_id) AS n
FROM listens WHERE member_id = ANY($1::uuid[])
) ranked
WHERE l.member_id = ranked.member_id AND l.source = ranked.source AND l.track_id = ranked.track_id AND ranked.n > $2::bigint
`
type TrimListensParams struct {
MemberIds []string
Keep int64
}
// 排序与 MemberListens 相同,所以留下的正是读取时返回的那些。
func (q *Queries) TrimListens(ctx context.Context, arg TrimListensParams) error {
_, err := q.db.Exec(ctx, trimListens, arg.MemberIds, arg.Keep)
return err
}
const upsertListens = `-- name: UpsertListens :exec
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT m, $1::text, $2::text, $3::text, $4::text, $5::text, $6::text,
$7::text, $8::bigint, $9::text, $10::timestamptz
FROM unnest($11::uuid[]) AS m
ON CONFLICT (member_id, 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, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at)
`
type UpsertListensParams struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
HeardAt time.Time
MemberIds []string
}
func (q *Queries) UpsertListens(ctx context.Context, arg UpsertListensParams) error {
_, err := q.db.Exec(ctx, upsertListens,
arg.Source,
arg.TrackID,
arg.Title,
arg.Artist,
arg.Album,
arg.CoverURL,
arg.CoverKey,
arg.DurationMs,
arg.PageURL,
arg.HeardAt,
arg.MemberIds,
)
return err
}
@@ -10,23 +10,6 @@ import (
"time"
)
const countStoredMedia = `-- name: CountStoredMedia :one
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
`
type CountStoredMediaRow struct {
Total int64
TotalBytes int64
}
func (q *Queries) CountStoredMedia(ctx context.Context, pattern string) (CountStoredMediaRow, error) {
row := q.db.QueryRow(ctx, countStoredMedia, pattern)
var i CountStoredMediaRow
err := row.Scan(&i.Total, &i.TotalBytes)
return i, err
}
const deleteStoredMedia = `-- name: DeleteStoredMedia :exec
DELETE FROM tracks WHERE media_key = $1::text
`
@@ -37,43 +20,24 @@ func (q *Queries) DeleteStoredMedia(ctx context.Context, mediaKey string) error
}
const listStoredMedia = `-- name: ListStoredMedia :many
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
ORDER BY CASE WHEN $2::text = 'stored' THEN prepared_at END DESC,
CASE WHEN $2::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
CASE WHEN $2::text = 'size' THEN media_size END DESC,
track_id COLLATE "C"
LIMIT $4::bigint OFFSET $3::bigint
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
`
type ListStoredMediaParams struct {
Pattern string
Sort string
Skip int64
PageSize int64
}
type ListStoredMediaRow struct {
MediaKey string
Source string
TrackID string
Title string
Artist *string
MediaSize int64
CoverURL *string
CoverKey *string
PreparedAt time.Time
LastPlayedAt *time.Time
}
// 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
func (q *Queries) ListStoredMedia(ctx context.Context, arg ListStoredMediaParams) ([]ListStoredMediaRow, error) {
rows, err := q.db.Query(ctx, listStoredMedia,
arg.Pattern,
arg.Sort,
arg.Skip,
arg.PageSize,
)
func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, error) {
rows, err := q.db.Query(ctx, listStoredMedia)
if err != nil {
return nil, err
}
@@ -86,9 +50,7 @@ func (q *Queries) ListStoredMedia(ctx context.Context, arg ListStoredMediaParams
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.MediaSize,
&i.CoverURL,
&i.CoverKey,
&i.PreparedAt,
&i.LastPlayedAt,
@@ -104,7 +66,7 @@ func (q *Queries) ListStoredMedia(ctx context.Context, arg ListStoredMediaParams
}
const storedMedia = `-- name: StoredMedia :one
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
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 = $1::text
`
@@ -113,9 +75,7 @@ type StoredMediaRow struct {
Source string
TrackID string
Title string
Artist *string
MediaSize int64
CoverURL *string
CoverKey *string
PreparedAt time.Time
LastPlayedAt *time.Time
@@ -129,9 +89,7 @@ func (q *Queries) StoredMedia(ctx context.Context, mediaKey string) (StoredMedia
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.MediaSize,
&i.CoverURL,
&i.CoverKey,
&i.PreparedAt,
&i.LastPlayedAt,
@@ -9,50 +9,8 @@ import (
"context"
)
const accountByGitHub = `-- name: AccountByGitHub :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE github_id = $1
`
func (q *Queries) AccountByGitHub(ctx context.Context, githubID *int64) (Member, error) {
row := q.db.QueryRow(ctx, accountByGitHub, githubID)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const accountByID = `-- name: AccountByID :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = $1 AND (username IS NOT NULL OR github_id IS NOT NULL)
`
func (q *Queries) AccountByID(ctx context.Context, id string) (Member, error) {
row := q.db.QueryRow(ctx, accountByID, id)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const accountByUsername = `-- name: AccountByUsername :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE username = $1
SELECT id, name, avatar_hash, created_at, username, password_hash, email FROM members WHERE username = $1
`
func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) {
@@ -66,54 +24,13 @@ func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Memb
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const addMemberCredentials = `-- name: AddMemberCredentials :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL AND github_id IS NOT NULL
`
type AddMemberCredentialsParams struct {
Username *string
PasswordHash *string
ID string
}
// 只给只绑定了 GitHub 的账号设置。这种账号的“我喜欢”已经在绑定时创建了。
func (q *Queries) AddMemberCredentials(ctx context.Context, arg AddMemberCredentialsParams) (int64, error) {
result, err := q.db.Exec(ctx, addMemberCredentials, arg.Username, arg.PasswordHash, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const bindGitHub = `-- name: BindGitHub :one
UPDATE members SET github_id = $1
WHERE id = $2 AND github_id IS NULL
RETURNING (username IS NULL)::boolean AS was_guest
`
type BindGitHubParams struct {
GithubID *int64
ID string
}
// 只给还没有绑定 GitHub 的成员绑定。已经绑定过的成员不受影响,查询没有返回的行。was_guest 表示成员原来是游客,这次绑定就是注册。
func (q *Queries) BindGitHub(ctx context.Context, arg BindGitHubParams) (bool, error) {
row := q.db.QueryRow(ctx, bindGitHub, arg.GithubID, arg.ID)
var was_guest bool
err := row.Scan(&was_guest)
return was_guest, err
}
const createMember = `-- name: CreateMember :one
INSERT INTO members (name) VALUES ($1)
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
`
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
@@ -127,8 +44,6 @@ func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error)
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -194,7 +109,7 @@ const lockAccountPassword = `-- name: LockAccountPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 AND username IS NOT NULL FOR NO KEY UPDATE
`
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE。这样同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
func (q *Queries) LockAccountPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockAccountPassword, id)
var password_hash string
@@ -203,11 +118,10 @@ func (q *Queries) LockAccountPassword(ctx context.Context, id string) (string, e
}
const lockMemberPassword = `-- name: LockMemberPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 FOR NO KEY UPDATE
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 FOR SHARE
`
// 这把锁让修改密码等到这次登录提交之后才进行,所以修改密码随后删除会话时,会删掉这次登录签发的会话。
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE,这样同一个账号的几次登录会依次并入游客的历史。否则两次登录会按相反的顺序抢同一批历史行而死锁。
// FOR SHARE 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockMemberPassword, id)
var password_hash string
@@ -216,24 +130,23 @@ func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, er
}
const memberAccountProfile = `-- name: MemberAccountProfile :one
SELECT username, email, github_id FROM members WHERE id = $1
SELECT username, email FROM members WHERE id = $1
`
type MemberAccountProfileRow struct {
Username *string
Email *string
GithubID *int64
}
func (q *Queries) MemberAccountProfile(ctx context.Context, id string) (MemberAccountProfileRow, error) {
row := q.db.QueryRow(ctx, memberAccountProfile, id)
var i MemberAccountProfileRow
err := row.Scan(&i.Username, &i.Email, &i.GithubID)
err := row.Scan(&i.Username, &i.Email)
return i, err
}
const memberByToken = `-- name: MemberByToken :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
SELECT id, name, avatar_hash, created_at, username, password_hash, email FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
`
func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) {
@@ -247,8 +160,6 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -271,7 +182,7 @@ func (q *Queries) MemberHasSession(ctx context.Context, arg MemberHasSessionPara
const registerMember = `-- name: RegisterMember :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL AND github_id IS NULL
WHERE id = $3 AND username IS NULL
`
type RegisterMemberParams struct {
@@ -280,7 +191,7 @@ type RegisterMemberParams struct {
ID string
}
// 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码时还要删除其他会话,所以用 AddMemberCredentials。
// 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
func (q *Queries) RegisterMember(ctx context.Context, arg RegisterMemberParams) (int64, error) {
result, err := q.db.Exec(ctx, registerMember, arg.Username, arg.PasswordHash, arg.ID)
if err != nil {
@@ -306,37 +217,13 @@ func (q *Queries) SetMemberPassword(ctx context.Context, arg SetMemberPasswordPa
return result.RowsAffected(), nil
}
const takeSession = `-- name: TakeSession :one
DELETE FROM member_sessions WHERE token_hash = $1 RETURNING member_id
`
func (q *Queries) TakeSession(ctx context.Context, tokenHash []byte) (string, error) {
row := q.db.QueryRow(ctx, takeSession, tokenHash)
var member_id string
err := row.Scan(&member_id)
return member_id, err
}
const unbindGitHub = `-- name: UnbindGitHub :execrows
UPDATE members SET github_id = NULL WHERE id = $1 AND password_hash IS NOT NULL
`
// 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
func (q *Queries) UnbindGitHub(ctx context.Context, id string) (int64, error) {
result, err := q.db.Exec(ctx, unbindGitHub, id)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateMember = `-- name: UpdateMember :one
UPDATE members
SET name = coalesce($1, name),
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END,
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
email = CASE WHEN username IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
WHERE id = $4
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
`
type UpdateMemberParams struct {
@@ -346,7 +233,7 @@ type UpdateMemberParams struct {
ID string
}
// 只覆盖传入的列,所以分别修改昵称和头像的两个并发请求不会互相还原。头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Member, error) {
row := q.db.QueryRow(ctx, updateMember,
arg.Name,
@@ -363,8 +250,6 @@ func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Mem
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -9,15 +9,13 @@ import (
)
type Member struct {
ID string
Name string
AvatarHash *string
CreatedAt time.Time
Username *string
PasswordHash *string
Email *string
RecordListens bool
GithubID *int64
ID string
Name string
AvatarHash *string
CreatedAt time.Time
Username *string
PasswordHash *string
Email *string
}
type PlaylistItem struct {
@@ -38,7 +38,7 @@ type CopyPlaylistItemsParams struct {
FromID string
}
// 按请求的先后顺序复制,第一首的排序值最大。请求里重复的歌只取第一次出现的位置。
// 按请求的先后复制:第一首的排序值最大。请求里重复的歌只取第一次出现的位置。
func (q *Queries) CopyPlaylistItems(ctx context.Context, arg CopyPlaylistItemsParams) (int64, error) {
result, err := q.db.Exec(ctx, copyPlaylistItems,
arg.ToID,
@@ -53,39 +53,6 @@ func (q *Queries) CopyPlaylistItems(ctx context.Context, arg CopyPlaylistItemsPa
return result.RowsAffected(), nil
}
const copySharedPlaylistItems = `-- name: CopySharedPlaylistItems :one
WITH shared AS (
SELECT id FROM playlists WHERE share_id = $1::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT $2, i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied
`
type CopySharedPlaylistItemsParams struct {
ShareID string
ToID string
}
type CopySharedPlaylistItemsRow struct {
Found bool
Copied int64
}
// 确认分享还在和复制曲目放在同一条语句里,两者看到的是同一个快照,所以要么完整复制,要么读不到分享。不锁来源歌单,因为两个人同时复制
// 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
func (q *Queries) CopySharedPlaylistItems(ctx context.Context, arg CopySharedPlaylistItemsParams) (CopySharedPlaylistItemsRow, error) {
row := q.db.QueryRow(ctx, copySharedPlaylistItems, arg.ShareID, arg.ToID)
var i CopySharedPlaylistItemsRow
err := row.Scan(&i.Found, &i.Copied)
return i, err
}
const countPlaylistItems = `-- name: CountPlaylistItems :one
SELECT count(*) FROM playlist_items WHERE playlist_id = $1
`
@@ -215,25 +182,11 @@ func (q *Queries) InsertPlaylistItem(ctx context.Context, arg InsertPlaylistItem
return err
}
type InsertPlaylistItemsParams struct {
PlaylistID string
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
Position int64
}
const lockFavorites = `-- name: LockFavorites :exec
SELECT id FROM playlists WHERE owner_id = $1 AND kind = 'favorites' FOR UPDATE
`
// 新建歌单前锁住这个账号的“我喜欢”,这样同一个账号并发新建时会依次计数,不会越过上限。
// 新建歌单前锁住这个账号的“我喜欢”:同一个账号并发新建时依次计数,不会越过上限。
func (q *Queries) LockFavorites(ctx context.Context, ownerID string) error {
_, err := q.db.Exec(ctx, lockFavorites, ownerID)
return err
@@ -248,7 +201,7 @@ type LockPlaylistParams struct {
OwnerID string
}
// 改动曲目前锁住歌单,这样并发加入时会依次检查上限、分配排序值。删除歌单时先锁歌单行再级联删除曲目,所以其他操作也要先锁歌单再动曲目,否则会互相死锁。
// 改动曲目前锁住歌单:并发加入时依次检查上限、分配排序值。删除歌单先锁歌单行再级联删除曲目,其他操作也要先锁歌单再动曲目,否则会互相死锁。
func (q *Queries) LockPlaylist(ctx context.Context, arg LockPlaylistParams) (int64, error) {
row := q.db.QueryRow(ctx, lockPlaylist, arg.ID, arg.OwnerID)
var item_seq int64
@@ -291,7 +244,7 @@ func (q *Queries) MemberPlaylistCovers(ctx context.Context, ownerID string) ([]M
}
const memberPlaylists = `-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = $1
@@ -305,7 +258,6 @@ type MemberPlaylistsRow struct {
Name string
CreatedAt time.Time
UpdatedAt time.Time
ShareID *string
TrackCount int64
}
@@ -324,7 +276,6 @@ func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]Member
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
&i.ShareID,
&i.TrackCount,
); err != nil {
return nil, err
@@ -337,25 +288,6 @@ func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]Member
return items, nil
}
const playlistCover = `-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = $1 AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1
`
type PlaylistCoverRow struct {
CoverURL *string
CoverKey *string
}
func (q *Queries) PlaylistCover(ctx context.Context, playlistID string) (PlaylistCoverRow, error) {
row := q.db.QueryRow(ctx, playlistCover, playlistID)
var i PlaylistCoverRow
err := row.Scan(&i.CoverURL, &i.CoverKey)
return i, err
}
const playlistHasItem = `-- name: PlaylistHasItem :one
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = $1 AND source = $2 AND track_id = $3)
`
@@ -512,52 +444,6 @@ func (q *Queries) SetPlaylistSeq(ctx context.Context, arg SetPlaylistSeqParams)
return err
}
const setPlaylistShare = `-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = $1 WHERE id = $2 AND owner_id = $3
`
type SetPlaylistShareParams struct {
ShareID *string
ID string
OwnerID string
}
func (q *Queries) SetPlaylistShare(ctx context.Context, arg SetPlaylistShareParams) (int64, error) {
result, err := q.db.Exec(ctx, setPlaylistShare, arg.ShareID, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const sharedPlaylist = `-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = $1::text
`
type SharedPlaylistRow struct {
ID string
Kind string
Name string
OwnerName string
OwnerAvatarHash *string
}
func (q *Queries) SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylistRow, error) {
row := q.db.QueryRow(ctx, sharedPlaylist, shareID)
var i SharedPlaylistRow
err := row.Scan(
&i.ID,
&i.Kind,
&i.Name,
&i.OwnerName,
&i.OwnerAvatarHash,
)
return i, err
}
const touchPlaylist = `-- name: TouchPlaylist :exec
UPDATE playlists SET updated_at = now() WHERE id = $1
`
@@ -32,35 +32,11 @@ func (q *Queries) CreateTicket(ctx context.Context, arg CreateTicketParams) erro
return err
}
const existingTickets = `-- name: ExistingTickets :many
SELECT token_hash FROM room_tickets WHERE token_hash = ANY($1::bytea[])
`
func (q *Queries) ExistingTickets(ctx context.Context, tokenHashes [][]byte) ([][]byte, error) {
rows, err := q.db.Query(ctx, existingTickets, tokenHashes)
if err != nil {
return nil, err
}
defer rows.Close()
var items [][]byte
for rows.Next() {
var token_hash []byte
if err := rows.Scan(&token_hash); err != nil {
return nil, err
}
items = append(items, token_hash)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockRoomPassword = `-- name: LockRoomPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM rooms WHERE id = $1 FOR SHARE
`
// FOR SHARE 让修改密码和删除房间等到这次签发提交之后才进行。这样两者随后删除票据时,会把这一张也删掉。
// FOR SHARE 让修改密码和删除房间等到这次签发提交之后,它们随后删除票据时会删掉这一张。
func (q *Queries) LockRoomPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockRoomPassword, id)
var password_hash string
@@ -85,7 +85,7 @@ type SavePreparedTrackParams struct {
MediaSize *int64
}
// 准备任务只对未完成的任务去重,所以同一首歌可能先后准备两次,以后一次的结果为准。
// 准备任务只对未完成的任务去重,同一首歌可能先后准备两次,以后一次的结果为准。
func (q *Queries) SavePreparedTrack(ctx context.Context, arg SavePreparedTrackParams) error {
_, err := q.db.Exec(ctx, savePreparedTrack,
arg.Source,
@@ -106,57 +106,3 @@ func (q *Queries) SavePreparedTrack(ctx context.Context, arg SavePreparedTrackPa
)
return err
}
const trackMetas = `-- name: TrackMetas :many
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
FROM unnest($1::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest($2::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id
`
type TrackMetasParams struct {
Sources []string
TrackIds []string
}
type TrackMetasRow struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
PageURL string
DurationMs int64
}
func (q *Queries) TrackMetas(ctx context.Context, arg TrackMetasParams) ([]TrackMetasRow, error) {
rows, err := q.db.Query(ctx, trackMetas, arg.Sources, arg.TrackIds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TrackMetasRow
for rows.Next() {
var i TrackMetasRow
if err := rows.Scan(
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.PageURL,
&i.DurationMs,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
-119
View File
@@ -1,119 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
_ port.ListenRecorder = (*Store)(nil)
_ port.Listens = (*Store)(nil)
)
func (s *Store) RecordListen(ctx context.Context, memberIDs []string, l port.Listen) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
ids, err := q.LockListeners(ctx, memberIDs)
if err != nil || len(ids) == 0 {
return err
}
m := l.Meta
err = q.UpsertListens(ctx, query.UpsertListensParams{
MemberIds: ids,
Source: string(l.Track.Source),
TrackID: l.Track.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
HeardAt: l.HeardAt,
})
if err != nil {
return err
}
return q.TrimListens(ctx, query.TrimListensParams{MemberIds: ids, Keep: port.ListenLimit})
})
if err != nil {
return fmt.Errorf("给 %d 个成员记录听到的 %s:%s:%w", len(memberIDs), l.Track.Source, l.Track.ID, err)
}
return nil
}
// 要在替换会话的事务里调用。这时账号行已经被互斥地锁住,所以 moveListens 和同时进行的记录、同时登录这个账号的其他游客会依次写入账号的历史。
func moveListens(ctx context.Context, q *query.Queries, fromID, toID string) error {
if err := q.MoveListens(ctx, query.MoveListensParams{FromID: fromID, ToID: toID}); err != nil {
return err
}
return q.TrimListens(ctx, query.TrimListensParams{MemberIds: []string{toID}, Keep: port.ListenLimit})
}
func (s *Store) MemberListens(ctx context.Context, memberID string) ([]port.Listen, error) {
rows, err := s.q.MemberListens(ctx, query.MemberListensParams{MemberID: memberID, Max: port.ListenLimit})
if err != nil {
return nil, fmt.Errorf("读取成员 %s 的播放历史:%w", memberID, err)
}
listens := make([]port.Listen, len(rows))
for i, r := range rows {
listens[i] = port.Listen{
Track: track.Track{Source: track.Source(r.Source), ID: r.TrackID},
Meta: track.Meta{
Title: r.Title,
Artist: orEmpty(r.Artist),
Album: orEmpty(r.Album),
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
PageURL: r.PageURL,
Duration: time.Duration(r.DurationMs) * time.Millisecond,
},
HeardAt: r.HeardAt,
}
}
return listens, nil
}
func (s *Store) DeleteListen(ctx context.Context, memberID string, t track.Track) error {
if err := s.q.DeleteListen(ctx, query.DeleteListenParams{MemberID: memberID, Source: string(t.Source), TrackID: t.ID}); err != nil {
return fmt.Errorf("删除成员 %s 听过的 %s:%s:%w", memberID, t.Source, t.ID, err)
}
return nil
}
func (s *Store) ClearListens(ctx context.Context, memberID string) error {
if err := s.q.ClearListens(ctx, memberID); err != nil {
return fmt.Errorf("清空成员 %s 的播放历史:%w", memberID, err)
}
return nil
}
func (s *Store) ListenRecording(ctx context.Context, memberID string) (bool, error) {
on, err := s.q.ListenRecording(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return false, fmt.Errorf("读取成员 %s 是否记录播放历史:%w", memberID, err)
}
return on, nil
}
func (s *Store) SetListenRecording(ctx context.Context, memberID string, on bool) error {
n, err := s.q.SetListenRecording(ctx, query.SetListenRecordingParams{ID: memberID, RecordListens: on})
if err == nil && n == 0 {
err = port.ErrMemberNotFound
}
if err != nil {
return fmt.Errorf("设置成员 %s 是否记录播放历史:%w", memberID, err)
}
return nil
}
@@ -1,346 +0,0 @@
package postgres
import (
"errors"
"fmt"
"slices"
"sync"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var heard = time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
func sunnyAt(at time.Time) port.Listen {
return port.Listen{
Track: track.Track{Source: track.NetEase, ID: "186016"},
Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269500 * time.Millisecond},
HeardAt: at,
}
}
func clipAt(at time.Time) port.Listen {
return port.Listen{
Track: track.Track{Source: track.Link, ID: "https://example.com/a.mp3"},
Meta: track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: "https://example.com/a.mp3", Duration: time.Minute},
HeardAt: at,
}
}
func songAt(id string, at time.Time) port.Listen {
return port.Listen{Track: track.Track{Source: track.NetEase, ID: id}, Meta: track.Meta{Title: id, PageURL: track.NetEasePageURL(id)}, HeardAt: at}
}
func record(t *testing.T, store *Store, members []string, l port.Listen) {
t.Helper()
if err := store.RecordListen(t.Context(), members, l); err != nil {
t.Fatal(err)
}
}
// 时间统一换成 UTC,便于直接比较。
func listensOf(t *testing.T, store *Store, memberID string) []port.Listen {
t.Helper()
listens, err := store.MemberListens(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
for i := range listens {
listens[i].HeardAt = listens[i].HeardAt.UTC()
}
return listens
}
func heardIDs(listens []port.Listen) []string {
ids := make([]string, len(listens))
for i, l := range listens {
ids[i] = l.Track.ID
}
return ids
}
func TestRecordListenKeepsLatestHearingOfEachTrack(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
record(t, store, []string{alice.ID, bob.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID}, clipAt(heard.Add(time.Minute)))
if got, want := listensOf(t, store, alice.ID), []port.Listen{clipAt(heard.Add(time.Minute)), sunnyAt(heard)}; !slices.Equal(got, want) {
t.Errorf("alice 的历史 = %#v,期望最近听到的在前 %#v", got, want)
}
if got, want := listensOf(t, store, bob.ID), []port.Listen{sunnyAt(heard)}; !slices.Equal(got, want) {
t.Errorf("bob 的历史 = %#v,期望 %#v", got, want)
}
// 同一首歌再次记录时,heard_at 取较晚的,元数据用新的。
renamed := sunnyAt(heard.Add(-time.Hour))
renamed.Meta.Title = "晴天(重制)"
record(t, store, []string{alice.ID}, renamed)
want := sunnyAt(heard)
want.Meta.Title = "晴天(重制)"
if got := listensOf(t, store, alice.ID); !slices.Equal(got, []port.Listen{clipAt(heard.Add(time.Minute)), want}) {
t.Errorf("较早的一次记录之后 = %#v,期望保留较晚的时间、换成新的元数据", got)
}
record(t, store, []string{alice.ID}, sunnyAt(heard.Add(time.Hour)))
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"186016", "https://example.com/a.mp3"}) {
t.Errorf("再次听到《晴天》之后的顺序 = %v,期望它排到最前", got)
}
}
func TestRecordListenTruncatesToStoredPrecision(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
l := sunnyAt(heard.Add(1234567 * time.Nanosecond))
l.Meta.Duration = 3*time.Minute + 1234*time.Microsecond
record(t, store, []string{alice.ID}, l)
got := listensOf(t, store, alice.ID)
if len(got) != 1 || got[0].Meta.Duration != 3*time.Minute+time.Millisecond || !got[0].HeardAt.Equal(heard.Add(1234*time.Microsecond)) {
t.Errorf("历史 = %#v,期望时长按毫秒、时间按微秒截断", got)
}
}
func TestRecordListenSkipsMembersWhoTurnedRecordingOff(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if on, err := store.ListenRecording(t.Context(), bob.ID); err != nil || !on {
t.Fatalf("新成员的 ListenRecording = %v, %v,期望默认开启", on, err)
}
record(t, store, []string{bob.ID}, sunnyAt(heard))
if err := store.SetListenRecording(t.Context(), bob.ID, false); err != nil {
t.Fatal(err)
}
if on, err := store.ListenRecording(t.Context(), bob.ID); err != nil || on {
t.Errorf("关闭之后 ListenRecording = %v, %v", on, err)
}
record(t, store, []string{alice.ID, bob.ID, "01a15778-1911-7a28-8b2e-e08c2a25f388"}, clipAt(heard.Add(time.Minute)))
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"https://example.com/a.mp3"}) {
t.Errorf("alice 的历史 = %v", got)
}
if got := heardIDs(listensOf(t, store, bob.ID)); !slices.Equal(got, []string{"186016"}) {
t.Errorf("bob 关闭记录之后的历史 = %v,期望只有关闭之前的", got)
}
if err := store.SetListenRecording(t.Context(), bob.ID, true); err != nil {
t.Fatal(err)
}
record(t, store, []string{bob.ID}, clipAt(heard.Add(2*time.Minute)))
if got := heardIDs(listensOf(t, store, bob.ID)); len(got) != 2 {
t.Errorf("重新开启之后的历史 = %v,期望继续记录", got)
}
missing := "01a15778-1911-7a28-8b2e-e08c2a25f388"
if _, err := store.ListenRecording(t.Context(), missing); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员的 ListenRecording 错误 = %v,期望 ErrMemberNotFound", err)
}
if err := store.SetListenRecording(t.Context(), missing, false); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员的 SetListenRecording 错误 = %v,期望 ErrMemberNotFound", err)
}
}
func TestRecordListenKeepsLatest500(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
// 第 i 首在 heard 之后 i 分钟听到,s0 最早。
for _, m := range []string{alice.ID, bob.ID} {
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, 499) AS i`, m, heard); err != nil {
t.Fatal(err)
}
}
record(t, store, []string{alice.ID, bob.ID}, songAt("new", heard.Add(time.Hour*24)))
for _, m := range []string{alice.ID, bob.ID} {
got := heardIDs(listensOf(t, store, m))
if len(got) != port.ListenLimit || got[0] != "new" || got[len(got)-1] != "s1" {
t.Errorf("第 501 首之后有 %d 首,最新 %q、最早 %q,期望 500 首,s0 被删掉", len(got), got[0], got[len(got)-1])
}
}
// 比现有的都早的一首,记下之后它自己就是超出的部分。
record(t, store, []string{alice.ID}, songAt("old", heard.Add(-time.Hour)))
if got := heardIDs(listensOf(t, store, alice.ID)); slices.Contains(got, "old") || len(got) != port.ListenLimit {
t.Errorf("更早的一首记下之后有 %d 首,含它 = %v,期望不留", len(got), slices.Contains(got, "old"))
}
}
// 同时开播的几个房间里有同样的两个成员,各房间给出成员的顺序不同。并发写入都成功,每首歌只留一条。
func TestConcurrentRecordsOfSameMembers(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
var wg sync.WaitGroup
errs := make(chan error, 40)
for i := range 40 {
members := []string{alice.ID, bob.ID}
if i%2 == 1 {
members = []string{bob.ID, alice.ID}
}
wg.Go(func() {
errs <- store.RecordListen(t.Context(), members, songAt(fmt.Sprint(i/2%4), heard.Add(time.Duration(i)*time.Second)))
})
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
for _, m := range []string{alice.ID, bob.ID} {
if got := heardIDs(listensOf(t, store, m)); !slices.Equal(got, []string{"3", "2", "1", "0"}) {
t.Errorf("并发记录之后的历史 = %v,期望每首一条、按最后一次听到的时间排列", got)
}
}
}
func TestDeleteAndClearListens(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
record(t, store, []string{alice.ID, bob.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID, bob.ID}, clipAt(heard.Add(time.Minute)))
if err := store.DeleteListen(t.Context(), alice.ID, sunnyAt(heard).Track); err != nil {
t.Fatal(err)
}
if err := store.DeleteListen(t.Context(), alice.ID, track.Track{Source: track.QQ, ID: "missing"}); err != nil {
t.Errorf("删除不在历史里的歌 = %v,期望忽略", err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"https://example.com/a.mp3"}) {
t.Errorf("删除《晴天》之后 alice 的历史 = %v", got)
}
if err := store.ClearListens(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if got := listensOf(t, store, alice.ID); len(got) != 0 {
t.Errorf("清空之后 alice 的历史 = %v", got)
}
if got := heardIDs(listensOf(t, store, bob.ID)); len(got) != 2 {
t.Errorf("alice 删除和清空之后 bob 的历史 = %v,期望不受影响", got)
}
if _, err := pool.Exec(t.Context(), "DELETE FROM members WHERE id = $1", bob.ID); err != nil {
t.Fatal(err)
}
var left int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM listens WHERE member_id = $1", bob.ID).Scan(&left); err != nil || left != 0 {
t.Errorf("删除成员之后还剩 %d 条历史(%v),期望随成员删除", left, err)
}
}
func TestReplaceSessionMovesGuestListensIntoAccount(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
sunny := sunnyAt(heard.Add(time.Hour))
sunny.Meta.Title = "晴天(游客听到的)"
record(t, store, []string{alice.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID}, songAt("1", heard.Add(time.Minute)))
record(t, store, []string{guest.ID}, sunny)
record(t, store, []string{guest.ID}, clipAt(heard.Add(-time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "hash"); err != nil {
t.Fatal(err)
}
if got, want := listensOf(t, store, alice.ID), []port.Listen{sunny, songAt("1", heard.Add(time.Minute)), clipAt(heard.Add(-time.Hour))}; !slices.Equal(got, want) {
t.Errorf("登录之后账号的历史 = %#v,期望并入游客的历史 %#v", got, want)
}
if got := listensOf(t, store, guest.ID); len(got) != 0 {
t.Errorf("登录之后游客的历史 = %v,期望移走", got)
}
}
func TestReplaceSessionMergeFollowsRecordingRules(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
other := createMember(t, store, "other", "游客-5e6f")
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, 499) AS i`, alice.ID, heard); err != nil {
t.Fatal(err)
}
record(t, store, []string{guest.ID}, songAt("new", heard.Add(24*time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "hash"); err != nil {
t.Fatal(err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); len(got) != port.ListenLimit || got[0] != "new" || slices.Contains(got, "s0") {
t.Errorf("并入之后账号有 %d 首,期望 500 首、最早的 s0 被删掉", len(got))
}
if err := store.SetListenRecording(t.Context(), alice.ID, false); err != nil {
t.Fatal(err)
}
record(t, store, []string{other.ID}, songAt("other", heard.Add(48*time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-3"), "hash"); err != nil {
t.Fatal(err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); slices.Contains(got, "other") {
t.Error("账号关闭记录时不应并入游客的历史")
}
if got := listensOf(t, store, other.ID); len(got) != 0 {
t.Errorf("游客的历史 = %v,账号不并入时也随会话一起删掉", got)
}
}
func TestReplaceSessionKeepsListensWhenRejected(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
record(t, store, []string{guest.ID}, sunnyAt(heard))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "changed"); !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Fatalf("密码哈希不符时 = %v", err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("nobody"), alice.ID, tokenHash("alice-2"), "hash"); !errors.Is(err, port.ErrSessionNotFound) {
t.Fatalf("旧会话不存在时 = %v", err)
}
if got := listensOf(t, store, guest.ID); len(got) != 1 {
t.Errorf("登录被拒绝之后游客的历史 = %v,期望不变", got)
}
if got := listensOf(t, store, alice.ID); len(got) != 0 {
t.Errorf("登录被拒绝之后账号的历史 = %v,期望不变", got)
}
}
// 两台设备上的游客同时登录同一个账号,两边听过同一批歌,写入的先后相反。两次合并依次进行,都成功,账号里每首歌只留一条。
func TestConcurrentReplaceSessionsMergeIntoSameAccount(t *testing.T) {
store, pool := newStore(t)
const songs = 200
for round := range 5 {
alice := registered(t, store, fmt.Sprintf("alice-%d", round))
guests := []string{fmt.Sprintf("guest-%d-a", round), fmt.Sprintf("guest-%d-b", round)}
for i, token := range guests {
guest := createMember(t, store, token, "游客-"+token)
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, $3 - 1) AS i ORDER BY i * $4`, guest.ID, heard, songs, 1-2*i); err != nil {
t.Fatal(err)
}
}
start := make(chan struct{})
errs := make([]error, len(guests))
var wg sync.WaitGroup
for i, token := range guests {
wg.Go(func() {
<-start
errs[i] = store.ReplaceSession(t.Context(), tokenHash(token), alice.ID, tokenHash(token+"-alice"), "hash")
})
}
close(start)
wg.Wait()
if err := errors.Join(errs...); err != nil {
t.Fatalf("第 %d 轮同时登录:%v", round, err)
}
got := heardIDs(listensOf(t, store, alice.ID))
if len(got) != songs || got[0] != fmt.Sprintf("s%d", songs-1) || got[songs-1] != "s0" {
t.Errorf("第 %d 轮并入之后账号有 %d 首,期望 %d 首、每首一条、按听到的时间排列", round, len(got), songs)
}
}
}
+8 -40
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -16,45 +15,16 @@ import (
var _ port.MediaLibrary = (*Store)(nil)
var mediaSorts = map[port.MediaSort]string{port.MediaByStored: "stored", port.MediaByIdle: "idle", port.MediaBySize: "size"}
// 搜索的文字按字面匹配,所以 LIKE 的通配符和转义符都要转义。
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
func (s *Store) ListStoredMedia(ctx context.Context, q port.MediaQuery) (port.MediaPage, error) {
pattern := likeEscaper.Replace(q.Title)
var page port.MediaPage
// 总数和这一页在同一个快照里读,两者才对得上。
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
qtx := s.q.WithTx(tx)
count, err := qtx.CountStoredMedia(ctx, pattern)
if err != nil {
return err
}
page.Total, page.TotalBytes = int(count.Total), count.TotalBytes
// 页码来自客户端,可能大到算偏移量时溢出,所以偏移量超过总数时直接返回,不再查询。
if int64(q.Page-1) > count.Total/port.MediaPageSize {
return nil
}
rows, err := qtx.ListStoredMedia(ctx, query.ListStoredMediaParams{
Pattern: pattern,
Sort: mediaSorts[q.Sort],
Skip: int64(q.Page-1) * port.MediaPageSize,
PageSize: port.MediaPageSize,
})
if err != nil {
return err
}
page.Items = make([]port.StoredMedia, len(rows))
for i, r := range rows {
page.Items[i] = storedMedia(query.StoredMediaRow(r))
}
return nil
})
func (s *Store) ListStoredMedia(ctx context.Context) ([]port.StoredMedia, error) {
rows, err := s.q.ListStoredMedia(ctx)
if err != nil {
return port.MediaPage{}, fmt.Errorf("读取已存储的媒体:%w", err)
return nil, fmt.Errorf("读取已存储的媒体:%w", err)
}
return page, nil
media := make([]port.StoredMedia, len(rows))
for i, r := range rows {
media[i] = storedMedia(query.StoredMediaRow(r))
}
return media, nil
}
func (s *Store) StoredMedia(ctx context.Context, key string) (port.StoredMedia, bool, error) {
@@ -81,9 +51,7 @@ func storedMedia(r query.StoredMediaRow) port.StoredMedia {
Source: track.Source(r.Source),
Link: r.TrackID,
Title: r.Title,
Artist: orEmpty(r.Artist),
Size: r.MediaSize,
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
StoredAt: r.PreparedAt.UTC(),
LastPlayedAt: utc(r.LastPlayedAt),
+12 -122
View File
@@ -1,12 +1,7 @@
package postgres
import (
"cmp"
"fmt"
"math"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -15,11 +10,10 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key string, meta track.Meta, size int64) {
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key, cover string, size int64) {
t.Helper()
meta.Title, meta.PageURL = "标题 "+key, pageURL
p := port.PreparedTrack{
Meta: meta,
Meta: track.Meta{Title: "标题 " + key, CoverKey: track.ObjectKey(cover), PageURL: pageURL},
Result: track.Prepared{Duration: time.Minute, Gain: 1},
MediaKey: key,
MediaSize: size,
@@ -33,9 +27,9 @@ func TestStoredMedia(t *testing.T) {
store, _ := newStore(t)
a := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
saveStored(t, store, a, a.ID, "aaa", track.Meta{Artist: "周杰伦", CoverKey: "link/aaa/cover.jpg"}, 100)
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", track.Meta{CoverURL: "https://example.com/b.jpg"}, 200)
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", track.Meta{Artist: "某个 UP 主", CoverKey: "link/ccc/cover.jpg"}, 300)
saveStored(t, store, a, a.ID, "aaa", "link/aaa/cover.jpg", 100)
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", "", 200)
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", "link/ccc/cover.jpg", 300)
addTrack(t, store, netease("1"), track.Meta{Title: "晴天"}, track.Prepared{Duration: time.Minute, Gain: 1})
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
@@ -43,23 +37,22 @@ func TestStoredMedia(t *testing.T) {
played := room.Entry{ID: 1, Track: a, By: alice}
saveRoom(t, store, term, snap, room.RecordPlay{Entry: played, StartedAt: t0, EndedAt: t0.Add(time.Minute)})
page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored})
list, err := store.ListStoredMedia(t.Context())
if err != nil {
t.Fatal(err)
}
list := page.Items
if len(list) != 3 || list[0].Key != "ccc" || list[1].Key != "bbb" || list[2].Key != "aaa" {
t.Fatalf("已存储的媒体 = %+v,期望两首链接和一首 B 站的歌,后存入的在前", list)
}
got := list[2]
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Artist != "周杰伦" || got.Size != 100 || got.CoverURL != "" || got.CoverKey != "link/aaa/cover.jpg" ||
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Size != 100 || got.CoverKey != "link/aaa/cover.jpg" ||
got.StoredAt.IsZero() || got.LastPlayedAt == nil || !got.LastPlayedAt.Equal(t0) {
t.Errorf("播放过的链接 = %+v", got)
}
if b := list[1]; b.Source != track.Link || b.Artist != "" || b.CoverURL != "https://example.com/b.jpg" || b.CoverKey != "" || b.LastPlayedAt != nil {
t.Errorf("没有歌手、封面不在对象存储里、没有播放过的链接 = %+v", b)
if b := list[1]; b.Source != track.Link || b.CoverKey != "" || b.LastPlayedAt != nil {
t.Errorf("没有封面、没有播放过的链接 = %+v", b)
}
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.Artist != "某个 UP 主" || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
t.Errorf("B 站的歌 = %+v", c)
}
@@ -81,110 +74,7 @@ func TestStoredMedia(t *testing.T) {
t.Errorf("清除之后 %v 的准备结果 = %v, %v,期望没有", tr, ok, err)
}
}
if page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored}); err != nil || len(page.Items) != 1 || page.Total != 1 {
t.Errorf("清除之后的列表 = %+v, %v", page, err)
}
}
func mediaKeys(items []port.StoredMedia) []string {
keys := make([]string, len(items))
for i, m := range items {
keys[i] = m.Key
}
slices.Sort(keys)
return keys
}
// 共 120 首。存储时间和最近播放时间各不相同,其中 10 首没有播放过。每 3 首大小相同,要靠曲目 ID 分出先后。
// 有 3 首的标题含 50%_off。另外两首只在 % 或 _ 被当作通配符时才会匹配 50%_off。
func TestStoredMediaPages(t *testing.T) {
store, pool := newStore(t)
titles := map[int]string{5: "Summer 50%_off", 50: "50%_OFF 特价", 95: "全场50%_Off", 20: "50abc_off", 30: "50%Xoff", 40: `C:\music`}
all := make([]port.StoredMedia, 120)
var totalBytes int64
for i := range all {
// 曲目 ID 的顺序和存入的顺序不同,这样大小相同时有没有按曲目 ID 排才看得出来。
link := fmt.Sprintf("https://example.com/%03d.mp3", i*7%120)
m := port.StoredMedia{
Key: fmt.Sprintf("key%03d", i), Source: track.Link, Link: link, Title: cmp.Or(titles[i], fmt.Sprintf("第 %d 首", i)),
Size: int64(1000 + i*11%40), StoredAt: t0.Add(time.Duration(i*13%120) * time.Minute),
}
if i%12 != 0 {
played := t0.Add(time.Duration(i*17%120) * time.Hour)
m.LastPlayedAt = &played
}
saveStored(t, store, track.Track{Source: track.Link, ID: link}, link, m.Key, track.Meta{}, m.Size)
if _, err := pool.Exec(t.Context(), "UPDATE tracks SET title = $1, prepared_at = $2, last_played_at = $3 WHERE media_key = $4", m.Title, m.StoredAt, m.LastPlayedAt, m.Key); err != nil {
t.Fatal(err)
}
all[i] = m
totalBytes += m.Size
}
list := func(q port.MediaQuery) port.MediaPage {
t.Helper()
page, err := store.ListStoredMedia(t.Context(), q)
if err != nil {
t.Fatal(err)
}
return page
}
// 每一页都报告全部的数量和大小。翻过最后一页得到的是空页。
pages := func(sort port.MediaSort) []port.StoredMedia {
t.Helper()
var items []port.StoredMedia
for i, want := range []int{50, 50, 20, 0} {
page := list(port.MediaQuery{Page: i + 1, Sort: sort})
if len(page.Items) != want || page.Total != 120 || page.TotalBytes != totalBytes {
t.Fatalf("排序 %d 的第 %d 页有 %d 首,共 %d 首 %d 字节,期望 %d 首,共 120 首 %d 字节", sort, i+1, len(page.Items), page.Total, page.TotalBytes, want, totalBytes)
}
items = append(items, page.Items...)
}
if got, want := mediaKeys(items), mediaKeys(all); !slices.Equal(got, want) {
t.Fatalf("排序 %d 依次翻页 = %q,期望不重复也不遗漏", sort, got)
}
return items
}
inOrder := func(name string, items []port.StoredMedia, compare func(a, b port.StoredMedia) int) {
t.Helper()
for i := 1; i < len(items); i++ {
if compare(items[i-1], items[i]) >= 0 {
t.Fatalf("%s:%+v 排在了 %+v 之前", name, items[i-1], items[i])
}
}
}
byLink := func(a, b port.StoredMedia) int { return strings.Compare(a.Link, b.Link) }
inOrder("按存储时间", pages(port.MediaByStored), func(a, b port.StoredMedia) int { return b.StoredAt.Compare(a.StoredAt) })
idle := pages(port.MediaByIdle)
for i, m := range idle {
if (m.LastPlayedAt == nil) != (i < 10) {
t.Fatalf("按最近播放的第 %d 首 = %+v,期望前 10 首是没有播放过的", i+1, m)
}
}
inOrder("没有播放过的", idle[:10], byLink)
inOrder("按最近播放", idle[10:], func(a, b port.StoredMedia) int { return a.LastPlayedAt.Compare(*b.LastPlayedAt) })
inOrder("按大小", pages(port.MediaBySize), func(a, b port.StoredMedia) int { return cmp.Or(cmp.Compare(b.Size, a.Size), byLink(a, b)) })
if page := list(port.MediaQuery{Page: math.MaxInt, Sort: port.MediaBySize}); len(page.Items) != 0 || page.Total != 120 || page.TotalBytes != totalBytes {
t.Errorf("页码极大时 = %d 首,共 %d 首 %d 字节,期望没有,总数照常", len(page.Items), page.Total, page.TotalBytes)
}
for _, tt := range []struct {
title string
want []int
}{
{"50%_off", []int{5, 50, 95}},
{"OFF", []int{5, 20, 30, 50, 95}},
{`:\m`, []int{40}},
} {
var want []port.StoredMedia
var bytes int64
for _, i := range tt.want {
want = append(want, all[i])
bytes += all[i].Size
}
page := list(port.MediaQuery{Page: 1, Sort: port.MediaByStored, Title: tt.title})
if got := mediaKeys(page.Items); !slices.Equal(got, mediaKeys(want)) || page.Total != len(want) || page.TotalBytes != bytes {
t.Errorf("搜索 %q = %q,共 %d 首 %d 字节,期望 %q,共 %d 首 %d 字节", tt.title, got, page.Total, page.TotalBytes, mediaKeys(want), len(want), bytes)
}
if list, err := store.ListStoredMedia(t.Context()); err != nil || len(list) != 1 {
t.Errorf("清除之后的列表 = %+v, %v", list, err)
}
}
+7 -98
View File
@@ -66,7 +66,7 @@ func (s *Store) AccountProfile(ctx context.Context, memberID string) (port.Accou
if err != nil {
return port.AccountProfile{}, fmt.Errorf("读取成员 %s 的账号资料:%w", memberID, err)
}
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email), GitHub: row.GithubID != nil}, nil
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email)}, nil
}
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
@@ -87,27 +87,6 @@ func (s *Store) Register(ctx context.Context, memberID, username, passwordHash s
return nil
}
func (s *Store) AddCredentials(ctx context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
// UPDATE 先锁住成员行,再用新的语句删除会话。这样要等同时在用 GitHub 登录的设备提交之后才开始删,那台设备登录时签发的会话也会被删掉。
n, err := q.AddMemberCredentials(ctx, query.AddMemberCredentialsParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
if err != nil {
return usernameError(err)
}
if n == 0 {
return port.ErrAlreadyRegistered
}
deleted, err = q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
return err
})
if err != nil {
return nil, fmt.Errorf("给成员 %s 补设用户名和密码:%w", memberID, err)
}
return deleted, nil
}
func usernameError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_username_key" {
@@ -124,71 +103,7 @@ func (s *Store) AccountByUsername(ctx context.Context, username string) (port.Ac
if err != nil {
return port.Account{}, fmt.Errorf("按用户名查找成员:%w", err)
}
return accountOf(row), nil
}
func (s *Store) AccountByID(ctx context.Context, memberID string) (port.Account, error) {
row, err := s.q.AccountByID(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("读取成员 %s 的账号:%w", memberID, err)
}
return accountOf(row), nil
}
func (s *Store) AccountByGitHub(ctx context.Context, githubID int64) (port.Account, error) {
row, err := s.q.AccountByGitHub(ctx, &githubID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("按 GitHub 账号查找成员:%w", err)
}
return accountOf(row), nil
}
func accountOf(row query.Member) port.Account {
account := port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}
if row.GithubID != nil {
account.GitHubID = *row.GithubID
}
return account
}
func (s *Store) BindGitHub(ctx context.Context, memberID string, githubID int64) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
wasGuest, err := q.BindGitHub(ctx, query.BindGitHubParams{ID: memberID, GithubID: &githubID})
var pgErr *pgconn.PgError
switch {
case errors.Is(err, pgx.ErrNoRows):
return port.ErrGitHubAlreadyBound
case errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_github_id_key":
return port.ErrGitHubTaken
case err != nil:
return err
case !wasGuest:
return nil
}
return q.CreateFavorites(ctx, memberID)
})
if err != nil {
return fmt.Errorf("给成员 %s 绑定 GitHub:%w", memberID, err)
}
return nil
}
func (s *Store) UnbindGitHub(ctx context.Context, memberID string) error {
n, err := s.q.UnbindGitHub(ctx, memberID)
if err == nil && n == 0 {
err = port.ErrNoPassword
}
if err != nil {
return fmt.Errorf("给成员 %s 解绑 GitHub:%w", memberID, err)
}
return nil
return port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}, nil
}
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
@@ -201,20 +116,14 @@ func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberI
if current != passwordHash {
return port.ErrAccountPasswordChanged
}
previous, err := q.TakeSession(ctx, oldTokenHash)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrSessionNotFound
}
n, err := q.DeleteSession(ctx, oldTokenHash)
if err != nil {
return err
}
if err := q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: memberID}); err != nil {
return err
if n == 0 {
return port.ErrSessionNotFound
}
if previous == memberID {
return nil
}
return moveListens(ctx, q, previous, memberID)
return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: memberID})
})
if err != nil {
return fmt.Errorf("给成员 %s 更换会话:%w", memberID, err)
@@ -246,7 +155,7 @@ func (s *Store) ChangePassword(ctx context.Context, memberID, currentPasswordHas
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
// 先锁住成员行,再用新的语句查会话。这样要等并发的修改或重置提交之后才开始查,才能看到那次修改或重置删掉的会话。
// 先锁住成员行,再用新的语句查会话:并发的修改或重置提交之后才开始查,才能看到它删掉的会话。
current, err := q.LockAccountPassword(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrMemberNotFound
+1 -192
View File
@@ -5,7 +5,6 @@ import (
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/dihemoe/alisten/internal/app/port"
@@ -309,7 +308,7 @@ func TestChangePasswordChecksSessionAndPassword(t *testing.T) {
}
}
// 模拟 SetPassword 或另一次 ChangePassword 的事务,先不提交。事务里改密码哈希,删除 keep 以外的会话,keep 为 nil 时全部删除。
// 模拟 SetPassword 或另一次 ChangePassword 的事务,先不提交:改密码哈希,删除 keep 以外的会话,keep 为 nil 时全部删除。
func changePasswordMeanwhile(t *testing.T, store *Store, pool *pgxpool.Pool, memberID string, keep []byte, want error) {
t.Helper()
tx, err := pool.Begin(t.Context())
@@ -427,193 +426,3 @@ func TestReplaceSessionWaitsForPasswordChange(t *testing.T) {
t.Errorf("改密码之后还留下了按旧密码签发的会话,属于 %s", got)
}
}
func favoritesCount(t *testing.T, store *Store, memberID string) int {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
n := 0
for _, l := range lists {
if l.Favorites {
n++
}
}
return n
}
func TestBindGitHub(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{GitHub: true}) || !p.Registered() {
t.Errorf("绑定之后的 AccountProfile = %#v, %v,期望只绑定了 GitHub 的已注册账号", p, err)
}
want := port.Account{Member: alice, GitHubID: 42}
if got, err := store.AccountByGitHub(t.Context(), 42); err != nil || got != want {
t.Errorf("AccountByGitHub = %#v, %v,期望 %#v", got, err, want)
}
if got, err := store.AccountByID(t.Context(), alice.ID); err != nil || got != want {
t.Errorf("AccountByID = %#v, %v,期望 %#v", got, err, want)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("游客绑定 GitHub 就是注册,应创建一个“我喜欢”,实际 %d 个", n)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); !errors.Is(err, port.ErrGitHubTaken) {
t.Errorf("GitHub 账号已经绑定了别人 = %v,期望 ErrGitHubTaken", err)
}
if err := store.BindGitHub(t.Context(), alice.ID, 43); !errors.Is(err, port.ErrGitHubAlreadyBound) {
t.Errorf("已经绑定过的成员再绑定 = %v,期望 ErrGitHubAlreadyBound", err)
}
if p, err := store.AccountProfile(t.Context(), bob.ID); err != nil || p.Registered() || favoritesCount(t, store, bob.ID) != 0 {
t.Errorf("绑定失败之后 bob 的 AccountProfile = %#v, %v,应仍是游客", p, err)
}
if _, err := store.AccountByGitHub(t.Context(), 99); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("没有绑定的 GitHub 账号 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.AccountByID(t.Context(), bob.ID); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("游客的 AccountByID = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("alice@example.com")); err != nil {
t.Fatal(err)
}
var email string
if err := pool.QueryRow(t.Context(), "SELECT coalesce(email, 'NULL') FROM members WHERE id = $1", alice.ID).Scan(&email); err != nil || email != "alice@example.com" {
t.Errorf("只绑定了 GitHub 的账号保存邮箱之后 email 列 = %q, %v", email, err)
}
}
func TestAddCredentialsAfterGitHub(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("只绑定了 GitHub 的账号用 Register 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
guest := createMember(t, store, "guest", "游客-5e6f")
if _, err := store.AddCredentials(t.Context(), guest.ID, "guest", "hash-g", tokenHash("guest")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("游客用 AddCredentials 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
if err := store.Register(t.Context(), guest.ID, "taken", "hash-g"); err != nil {
t.Fatal(err)
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "taken", "hash-a", tokenHash("alice")); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
createMember(t, store, "other", "游客-7a8b")
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2"), ""); err != nil {
t.Fatal(err)
}
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
if err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
t.Fatalf("AddCredentials 返回 %x, %v,期望被删除的 alice-2", deleted, err)
}
if ownerOf(t, store, "alice") != alice.ID || ownerOf(t, store, "alice-2") != "" {
t.Error("AddCredentials 应只留下发出请求的会话")
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "alice2", "hash-c", tokenHash("alice")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("再补设一次 = %v,期望 ErrAlreadyRegistered", err)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("只绑定了 GitHub 的账号补设用户名之后有 %d 个“我喜欢”,期望仍是 1 个", n)
}
want := port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a", GitHubID: 42}
if got, err := store.AccountByUsername(t.Context(), "alice"); err != nil || got != want {
t.Errorf("AccountByUsername = %#v, %v,期望 %#v", got, err, want)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{Username: "alice"}) {
t.Errorf("解绑之后的 AccountProfile = %#v, %v", p, err)
}
if _, err := store.AccountByGitHub(t.Context(), 42); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("解绑之后按 GitHub 查找 = %v,期望 ErrMemberNotFound", err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Errorf("没有绑定时解绑 = %v,期望什么也不做", err)
}
bob := createMember(t, store, "bob", "游客-3c4d")
if err := store.Register(t.Context(), bob.ID, "bob", "hash-b"); err != nil {
t.Fatal(err)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); err != nil {
t.Fatal(err)
}
if n := favoritesCount(t, store, bob.ID); n != 1 {
t.Errorf("已注册的账号绑定 GitHub 之后有 %d 个“我喜欢”,期望仍是 1 个", n)
}
}
func TestOnlyAccountsHavePasswordOrEmail(t *testing.T) {
store, pool := newStore(t)
guest := createMember(t, store, "guest", "游客-1a2b")
for _, c := range []struct{ set, constraint string }{
{"password_hash = 'hash'", "members_password_check"},
{"email = 'guest@example.com'", "members_email_check"},
} {
_, err := pool.Exec(t.Context(), "UPDATE members SET "+c.set+" WHERE id = $1", guest.ID)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23514" || pgErr.ConstraintName != c.constraint {
t.Errorf("给没有用户名的游客设置 %s,错误 = %v,期望违反 %s", c.set, err, c.constraint)
}
}
}
// 补设用户名和密码时,用 GitHub 登录的另一台设备恰好在签发会话。补设要等那台设备的事务提交,再把它签发的会话一并删除。
func TestAddCredentialsWaitsForGitHubLogin(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
// 模拟 ReplaceSession 的事务,先不提交。
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "SELECT 1 FROM members WHERE id = $1 FOR NO KEY UPDATE", alice.ID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE token_hash = $1", tokenHash("guest")); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)", tokenHash("alice-2"), alice.ID); err != nil {
t.Fatal(err)
}
type result struct {
deleted [][]byte
err error
}
done := make(chan result, 1)
go func() {
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
done <- result{deleted, err}
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if r := <-done; r.err != nil || !sameHashes(r.deleted, [][]byte{tokenHash("alice-2")}) {
t.Errorf("登录提交之后,AddCredentials 返回 %x, %v,期望删除登录签发的 alice-2", r.deleted, r.err)
}
if ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice") != alice.ID {
t.Error("补设之后应只留下发出请求的会话")
}
}
+3 -3
View File
@@ -17,10 +17,10 @@ import (
const migrationLock int64 = 0x616c697374656e
// 锁所在的事务一直占着一个连接,所以 fn 里的迁移要用连接池里的其他连接。
// 锁所在的事务一直占用一个连接,fn 中的迁移要使用连接池中的其他连接。
func WithMigrationLock(ctx context.Context, pool *pgxpool.Pool, fn func(context.Context) error) error {
return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
// 这个事务在整个迁移期间都处于空闲状态,数据库设置的空闲事务超时或事务超时会断开这个事务,锁也就随之失效。
// 这个事务在整个迁移期间都处于空闲状态,数据库设置的空闲事务超时或事务超时会断开它,锁随之失效。
// 后到的迁移要一直等待锁,而数据库设置的 lock_timeout、statement_timeout 和 transaction_timeout 会让它报错而不是等待。
if _, err := tx.Exec(ctx, `SELECT set_config('idle_in_transaction_session_timeout', '0', true),
set_config('lock_timeout', '0', true), set_config('statement_timeout', '0', true),
@@ -52,7 +52,7 @@ func MigrateSchema(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger)
}
func MigrateRiver(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error {
// rivermigrate 的日志带有 version 字段,而 slog 会把重名的字段写两次。放进 river 分组,rivermigrate 的 version 才不会和日志的固定字段 version 重名。
// rivermigrate 的日志带有 version 字段,而 slog 会把重名的字段写两次:放进 river 分组,它才不会与日志的固定字段 version 重名。
migrator, err := rivermigrate.New(riverpgxv5.New(pool), &rivermigrate.Config{Logger: logger.WithGroup("river")})
if err != nil {
return fmt.Errorf("创建 River 迁移器:%w", err)
@@ -66,7 +66,7 @@ func TestMigrateWaitsForMigrationLock(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
cfg := newDatabase(t, "template1")
// 先完成迁移,这样等到锁之后的迁移只剩检查,不会被 statement_timeout 打断。
// 先完成迁移:等到锁之后的迁移只剩检查,不会被 statement_timeout 打断。
if err := migrate(t.Context(), connect(t, cfg), discard); err != nil {
t.Fatal(err)
}
+16 -150
View File
@@ -42,7 +42,6 @@ func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.Pla
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
Cover: covers[r.ID],
ShareID: orEmpty(r.ShareID),
}
}
return lists, nil
@@ -50,8 +49,19 @@ func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.Pla
func (s *Store) CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (port.PlaylistRecord, error) {
var created query.CreatePlaylistRow
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
created, err = createPlaylist(ctx, s.q.WithTx(tx), ownerID, name, limit)
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
if err := q.LockFavorites(ctx, ownerID); err != nil {
return err
}
n, err := q.CountPlaylists(ctx, ownerID)
if err != nil {
return err
}
if n >= int64(limit) {
return port.ErrPlaylistLimit
}
created, err = q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
return err
})
if err != nil {
@@ -60,66 +70,6 @@ func (s *Store) CreatePlaylist(ctx context.Context, ownerID, name string, limit
return port.PlaylistRecord{ID: created.ID, Name: name, CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}, nil
}
func (s *Store) ImportPlaylist(ctx context.Context, ownerID, name string, items []port.PlaylistItem, limit int) (port.PlaylistRecord, error) {
var created query.CreatePlaylistRow
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
q := s.q.WithTx(tx)
if created, err = createPlaylist(ctx, q, ownerID, name, limit); err != nil {
return err
}
seq := int64(len(items))
rows := make([]query.InsertPlaylistItemsParams, len(items))
for i, item := range items {
t, m := item.Track, item.Meta
rows[i] = query.InsertPlaylistItemsParams{
PlaylistID: created.ID,
Source: string(t.Source),
TrackID: t.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
Position: seq - int64(i),
}
}
if _, err = q.InsertPlaylistItems(ctx, rows); err != nil {
return err
}
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: seq})
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 导入 %d 首歌到新歌单:%w", ownerID, len(items), err)
}
return port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: len(items), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt, Cover: firstCover(items)}, nil
}
// 要在事务里调用,这样“我喜欢”的行锁会持续到新建之后,同一个账号并发新建时才会依次计数。
func createPlaylist(ctx context.Context, q *query.Queries, ownerID, name string, limit int) (query.CreatePlaylistRow, error) {
if err := q.LockFavorites(ctx, ownerID); err != nil {
return query.CreatePlaylistRow{}, err
}
n, err := q.CountPlaylists(ctx, ownerID)
if err != nil {
return query.CreatePlaylistRow{}, err
}
if n >= int64(limit) {
return query.CreatePlaylistRow{}, port.ErrPlaylistLimit
}
return q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
}
func firstCover(items []port.PlaylistItem) *port.Cover {
for _, item := range items {
if m := item.Meta; m.CoverURL != "" || m.CoverKey != "" {
return &port.Cover{URL: m.CoverURL, Key: m.CoverKey}
}
}
return nil
}
func (s *Store) RenamePlaylist(ctx context.Context, ownerID, id, name string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
@@ -148,7 +98,7 @@ func (s *Store) DeletePlaylist(ctx context.Context, ownerID, id string) error {
return nil
}
// 改名或删除没有影响任何行,说明要么是“我喜欢”,要么歌单不属于这个成员。
// 改名或删除没有影响任何行:要么是“我喜欢”,要么歌单不属于这个成员。
func (s *Store) unchangeable(ctx context.Context, ownerID, id string) error {
kind, err := s.q.PlaylistKind(ctx, query.PlaylistKindParams{ID: id, OwnerID: ownerID})
switch {
@@ -181,10 +131,6 @@ func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.P
if err != nil {
return nil, fmt.Errorf("读取歌单 %s 的曲目:%w", id, err)
}
return playlistItems(rows), nil
}
func playlistItems(rows []query.PlaylistItem) []port.PlaylistItem {
items := make([]port.PlaylistItem, len(rows))
for i, r := range rows {
items[i] = port.PlaylistItem{
@@ -200,7 +146,7 @@ func playlistItems(rows []query.PlaylistItem) []port.PlaylistItem {
},
}
}
return items
return items, nil
}
func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
@@ -343,7 +289,7 @@ func (s *Store) MovePlaylistItems(ctx context.Context, ownerID, id string, track
if err != nil || slices.Equal(moved, order) {
return err
}
// 显示顺序里最靠前的歌取最大的排序值。歌单的 item_seq 不小于曲目数,所以新加入的歌仍然排在最前。
// 显示顺序里最靠前的取最大的排序值;歌单的 item_seq 不小于曲目数,所以新加入的歌仍然排在最前。
positions := make([]int64, len(moved))
for i := range moved {
positions[i] = int64(len(moved) - i)
@@ -360,86 +306,6 @@ func (s *Store) MovePlaylistItems(ctx context.Context, ownerID, id string, track
return nil
}
func (s *Store) SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.SetPlaylistShare(ctx, query.SetPlaylistShareParams{ID: id, OwnerID: ownerID, ShareID: nullable(shareID)})
if err == nil && n == 0 {
err = port.ErrPlaylistNotFound
}
if err != nil {
return fmt.Errorf("设置歌单 %s 的分享:%w", id, err)
}
return nil
}
func (s *Store) SharedPlaylist(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
var shared port.SharedPlaylist
// 歌单和曲目在同一个快照里读。如果分开读,删除歌单可能恰好发生在两次读取之间,就会读到歌单还在、曲目却已经没了。
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
p, err := q.SharedPlaylist(ctx, shareID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrShareNotFound
}
if err != nil {
return err
}
rows, err := q.PlaylistItems(ctx, p.ID)
if err != nil {
return err
}
shared = port.SharedPlaylist{
Name: p.Name,
Favorites: p.Kind == favoritesKind,
OwnerName: p.OwnerName,
OwnerAvatarHash: orEmpty(p.OwnerAvatarHash),
Items: playlistItems(rows),
}
return nil
})
if err != nil {
return port.SharedPlaylist{}, fmt.Errorf("读取分享的歌单:%w", err)
}
return shared, nil
}
func (s *Store) CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
var record port.PlaylistRecord
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
created, err := createPlaylist(ctx, q, ownerID, name, limit)
if err != nil {
return err
}
copied, err := q.CopySharedPlaylistItems(ctx, query.CopySharedPlaylistItemsParams{ShareID: shareID, ToID: created.ID})
if err != nil {
return err
}
if !copied.Found {
return port.ErrShareNotFound
}
if err := q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: copied.Copied}); err != nil {
return err
}
record = port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: int(copied.Copied), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}
cover, err := q.PlaylistCover(ctx, created.ID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
record.Cover = &port.Cover{URL: orEmpty(cover.CoverURL), Key: track.ObjectKey(orEmpty(cover.CoverKey))}
return nil
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 复制分享的歌单:%w", ownerID, err)
}
return record, nil
}
func splitTracks(tracks []track.Track) (sources, ids []string) {
sources, ids = make([]string, len(tracks)), make([]string, len(tracks))
for i, t := range tracks {
+1 -245
View File
@@ -171,7 +171,7 @@ func TestPlaylistItems(t *testing.T) {
}
func TestConcurrentPlaylistAddsRespectLimit(t *testing.T) {
store, pool := newStore(t)
store, _ := newStore(t)
alice := registered(t, store, "alice")
favorites := favoritesOf(t, store, alice.ID)
var wg sync.WaitGroup
@@ -185,15 +185,6 @@ func TestConcurrentPlaylistAddsRespectLimit(t *testing.T) {
if items, err := store.PlaylistItems(t.Context(), alice.ID, favorites); err != nil || len(items) != 5 {
t.Errorf("并发加入之后有 %d 首, %v,期望正好 5 首", len(items), err)
}
var distinct, seq int64
var largest int64
if err := pool.QueryRow(t.Context(), `SELECT count(DISTINCT i.position), max(i.position), max(p.item_seq)
FROM playlist_items i JOIN playlists p ON p.id = i.playlist_id WHERE i.playlist_id = $1`, favorites).Scan(&distinct, &largest, &seq); err != nil {
t.Fatal(err)
}
if distinct != 5 || largest > seq {
t.Errorf("排序值有 %d 种、最大 %d,歌单的 item_seq 是 %d,期望 5 首的排序值两两不同且都不大于 item_seq", distinct, largest, seq)
}
}
func TestConcurrentPlaylistCreatesRespectLimit(t *testing.T) {
@@ -610,238 +601,3 @@ func TestMovePlaylistItemsAdvancesUpdatedAt(t *testing.T) {
t.Error("移动之后更新时间没有推进")
}
}
func TestImportPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
items := []port.PlaylistItem{songItem("3"), clipItem, songItem("1")}
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "导入的歌单", items, 50)
if err != nil {
t.Fatal(err)
}
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || !slices.Equal(got, items) {
t.Errorf("导入之后的曲目 = %#v, %v,期望与来源同序 %#v", got, err, items)
}
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和导入的歌单", lists, err)
}
read := lists[1]
if imported.ID != read.ID || imported.Name != "导入的歌单" || imported.Favorites || imported.TrackCount != 3 ||
!imported.CreatedAt.Equal(read.CreatedAt) || !imported.UpdatedAt.Equal(read.UpdatedAt) {
t.Errorf("导入返回 %#v,期望与读出的 %#v 一致、共 3 首", imported, read)
}
if want := (port.Cover{URL: songItem("3").Meta.CoverURL}); imported.Cover == nil || *imported.Cover != want || read.Cover == nil || *read.Cover != want {
t.Errorf("封面 = %+v / %+v,期望排在最前的 %+v", imported.Cover, read.Cover, want)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, imported.ID, songItem("9"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, alice.ID, imported.ID); !slices.Equal(got, []string{"9", "3", clipItem.Track.ID, "1"}) {
t.Errorf("导入之后再加入 = %v,期望新加入的排在最前", got)
}
}
func TestImportEmptyPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "空的", nil, 50)
if err != nil || imported.Name != "空的" || imported.TrackCount != 0 || imported.Cover != nil {
t.Fatalf("导入空列表 = %#v, %v,期望建出一个没有曲目的歌单", imported, err)
}
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || len(got) != 0 {
t.Errorf("曲目 = %#v, %v,期望为空", got, err)
}
}
func TestImportPlaylistRollsBackAsAWhole(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
items := []port.PlaylistItem{songItem("1"), songItem("2")}
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "超限", items, 1); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
}
broken := songItem("3")
broken.Meta.Title = "坏\x00标题"
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "写入失败", append(items, broken), 50); err == nil {
t.Error("有一首写不进去时应报错")
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
t.Errorf("失败之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
}
var count int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items`).Scan(&count); err != nil || count != 0 {
t.Errorf("失败之后的曲目数 = %d, %v,期望 0", count, err)
}
}
func TestConcurrentImportsRespectLimit(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
_, _ = store.ImportPlaylist(t.Context(), alice.ID, "导入"+strconv.Itoa(i), []port.PlaylistItem{songItem(strconv.Itoa(i))}, 5)
})
}
wg.Wait()
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 5 {
t.Errorf("并发导入之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err)
}
}
func TestSharePlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
hash := "0bc83cb571cd1c50ba6f3e8a78ef1346"
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, &hash, nil); err != nil {
t.Fatal(err)
}
favorites := favoritesOf(t, store, alice.ID)
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, sunnyItem, 1000); err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, clipItem, 1000); err != nil {
t.Fatal(err)
}
before := updatedAt(t, store, alice.ID, favorites)
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-1"); err != nil {
t.Fatal(err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "share-1" {
t.Errorf("歌单 = %#v, %v,期望带上分享 ID", lists, err)
}
if !updatedAt(t, store, alice.ID, favorites).Equal(before) {
t.Error("分享改变了歌单的更新时间")
}
shared, err := store.SharedPlaylist(t.Context(), "share-1")
want := port.SharedPlaylist{Name: "我喜欢", Favorites: true, OwnerName: alice.Name, OwnerAvatarHash: hash, Items: []port.PlaylistItem{clipItem, sunnyItem}}
if err != nil || shared.Name != want.Name || shared.Favorites != want.Favorites || shared.OwnerName != want.OwnerName ||
shared.OwnerAvatarHash != want.OwnerAvatarHash || !slices.Equal(shared.Items, want.Items) {
t.Errorf("分享的歌单 = %#v, %v,期望 %#v", shared, err, want)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-2"); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("重新生成之后的旧链接 = %v,期望 ErrShareNotFound", err)
}
if shared, err := store.SharedPlaylist(t.Context(), "share-2"); err != nil || len(shared.Items) != 2 {
t.Errorf("新链接 = %#v, %v", shared, err)
}
for _, id := range []string{favorites, "not-a-uuid", "00000000-0000-7000-8000-000000000000"} {
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, "share-3"); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, ""); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("停止分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
}
for range 2 {
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, ""); err != nil {
t.Fatalf("停止分享 = %v,期望重复停止也成功", err)
}
}
if _, err := store.SharedPlaylist(t.Context(), "share-2"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "" {
t.Errorf("停止分享之后的歌单 = %#v, %v,期望没有分享 ID", lists, err)
}
}
func TestShareIDIsUnique(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
if err := store.SetPlaylistShare(t.Context(), alice.ID, favoritesOf(t, store, alice.ID), "share-1"); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, favoritesOf(t, store, bob.ID), "share-1"); err == nil {
t.Error("同一个分享 ID 设给第二个歌单时应报错")
}
if shared, err := store.SharedPlaylist(t.Context(), "share-1"); err != nil || shared.OwnerName != alice.Name {
t.Errorf("分享 = %#v, %v,期望仍是第一个歌单", shared, err)
}
}
func TestDeletedPlaylistIsNoLongerShared(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
list := filled(t, store, alice.ID, "通勤", "1")
if err := store.SetPlaylistShare(t.Context(), alice.ID, list, "share-1"); err != nil {
t.Fatal(err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, list); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("删除歌单之后 = %v,期望 ErrShareNotFound", err)
}
}
func TestCopySharedPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
source := filled(t, store, alice.ID, "通勤", "1", "2", "3")
if err := store.MovePlaylistItems(t.Context(), alice.ID, source, trackList("1"), &trackList("3")[0]); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, source, "share-1"); err != nil {
t.Fatal(err)
}
original := displayIDs(t, store, alice.ID, source)
copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "抄来的", 50)
if err != nil {
t.Fatal(err)
}
if want := (port.Cover{URL: songItem("1").Meta.CoverURL}); copied.Name != "抄来的" || copied.Favorites || copied.TrackCount != 3 ||
copied.ShareID != "" || copied.Cover == nil || *copied.Cover != want {
t.Errorf("复制返回 %#v,期望 3 首、封面是排在最前的 %+v、没有分享", copied, want)
}
lists, err := store.MemberPlaylists(t.Context(), bob.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和复制的歌单", lists, err)
}
if read := lists[1]; read.ID != copied.ID || read.Name != copied.Name || read.TrackCount != 3 || read.ShareID != "" ||
!read.CreatedAt.Equal(copied.CreatedAt) || !read.UpdatedAt.Equal(copied.UpdatedAt) {
t.Errorf("读出的 %#v,期望与复制返回的 %#v 一致", read, copied)
}
items, err := store.PlaylistItems(t.Context(), bob.ID, copied.ID)
sourceItems, _ := store.PlaylistItems(t.Context(), alice.ID, source)
if err != nil || !slices.Equal(items, sourceItems) {
t.Errorf("复制的曲目 = %#v, %v,期望与原歌单相同 %#v", items, err, sourceItems)
}
if err := store.AddPlaylistItem(t.Context(), bob.ID, copied.ID, songItem("9"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, bob.ID, copied.ID); !slices.Equal(got, append([]string{"9"}, original...)) {
t.Errorf("复制之后再加入 = %v,期望新加入的排在最前、其余保持 %v", got, original)
}
if got := displayIDs(t, store, alice.ID, source); !slices.Equal(got, original) {
t.Errorf("原歌单 = %v,期望不变 %v", got, original)
}
empty := filled(t, store, alice.ID, "空的")
if err := store.SetPlaylistShare(t.Context(), alice.ID, empty, "share-2"); err != nil {
t.Fatal(err)
}
if copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-2", "空的", 50); err != nil || copied.TrackCount != 0 || copied.Cover != nil {
t.Errorf("复制空歌单 = %#v, %v,期望建出一个没有曲目的歌单", copied, err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "超限", 3); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-x", "失效", 50); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("读不到的分享 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), bob.ID); err != nil || len(lists) != 3 {
t.Errorf("失败之后有 %d 个歌单, %v,期望没有多建", len(lists), err)
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"github.com/dihemoe/alisten/db/migrations"
)
// 只读取版本表,不用 goose 的 HasPending,因为 HasPending 在缺少版本表时会建表,而就绪检查不能修改数据库。
// 只读取版本表:goose 的 HasPending 在缺少版本表时会建表,就绪检查不能修改数据库。
func CheckMigrations(ctx context.Context, pool *pgxpool.Pool) error {
db := stdlib.OpenDBFromPool(pool)
defer func() { _ = db.Close() }()
+2 -2
View File
@@ -149,8 +149,8 @@ func entryOf(r query.RoomEntriesRow) room.Entry {
Round: int(r.Round),
AddedAt: r.AddedAt.UTC(),
}
// 曲目表只保存准备成功的结果,有对应的行就是已就绪。元数据也取自曲目表,因为在房间没有 owner 时准备好的歌,
// 条目里还是点歌时的元数据,比如链接曲目的标题还是链接本身。
// 曲目表只保存准备成功的结果,有对应的行就是已就绪。元数据也取自曲目表:在房间没有 owner 时准备好的歌,
// 条目中仍是点歌时的元数据,比如链接曲目的标题仍是链接本身。
if r.DurationMs != nil {
e.Ready = true
e.Meta, e.Prep = prepared(*r.TrackTitle, orEmpty(r.TrackArtist), orEmpty(r.TrackAlbum), orEmpty(r.TrackCoverURL), orEmpty(r.TrackCoverKey), *r.TrackPageURL, *r.DurationMs, *r.Gain, orEmpty(r.Lyric), orEmpty(r.LyricWords), orEmpty(r.LyricTrans))
@@ -80,7 +80,7 @@ func TestSavedSnapshotIsRestored(t *testing.T) {
addTrack(t, store, netease("1"), meta, playing)
addTrack(t, store, link, linkMeta, queued)
// 准备中的链接曲目也可能带有封面的对象键,比如音频已不在对象存储里、要重新准备的歌。
// 准备中的链接曲目也可能带有封面的对象键:例如音频已不在对象存储中、要重新准备的歌。
unprepared := track.Track{Source: track.Link, ID: "https://example.com/c.mp3"}
saved := room.Snapshot{
Info: room.Info{ID: id, Name: "新名字", Description: "新描述", Kind: room.Persistent},
+1 -1
View File
@@ -19,7 +19,7 @@ func New(pool *pgxpool.Pool) *Store {
var uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
// 房间 ID 可能来自客户端。格式不正确的 uuid 参数会让 PostgreSQL 报错,而这样的房间一定不存在。
// 房间 ID 可能来自客户端:格式不正确的 uuid 参数会让 PostgreSQL 报错,而这样的房间一定不存在。
func isUUID(s string) bool {
return uuidPattern.MatchString(s)
}
+1 -12
View File
@@ -13,10 +13,7 @@ import (
"github.com/dihemoe/alisten/internal/domain/member"
)
var (
_ port.Tickets = (*Store)(nil)
_ port.ExistingTickets = (*Store)(nil)
)
var _ port.Tickets = (*Store)(nil)
func (s *Store) CreateTicket(ctx context.Context, t port.NewTicket) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
@@ -55,14 +52,6 @@ func (s *Store) TicketHolder(ctx context.Context, tokenHash []byte) (port.Ticket
}, nil
}
func (s *Store) ExistingTickets(ctx context.Context, tokenHashes [][]byte) ([][]byte, error) {
existing, err := s.q.ExistingTickets(ctx, tokenHashes)
if err != nil {
return nil, fmt.Errorf("核对 %d 张票据:%w", len(tokenHashes), err)
}
return existing, nil
}
const foreignKeyViolation = "23503"
func sessionError(err error) error {
@@ -39,30 +39,6 @@ func TestTicketHolder(t *testing.T) {
}
}
func TestExistingTickets(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
for _, ticket := range []string{"t1", "t2"} {
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash(ticket), RoomID: id, MemberID: alice.ID, SessionTokenHash: tokenHash("alice")}); err != nil {
t.Fatal(err)
}
}
resetAPIKey(t, store, id, "key")
got, err := store.ExistingTickets(t.Context(), [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key"), tokenHash("t3"), tokenHash("t1")})
if want := [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key")}; err != nil || !sameHashes(got, want) {
t.Errorf("ExistingTickets = %x, %v,期望 t1、t2 和 API Key 各一次", got, err)
}
if err := store.DeleteSession(t.Context(), tokenHash("alice")); err != nil {
t.Fatal(err)
}
got, err = store.ExistingTickets(t.Context(), [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key")})
if want := [][]byte{tokenHash("key")}; err != nil || !sameHashes(got, want) {
t.Errorf("会话删除之后 ExistingTickets = %x, %v,期望只剩 API Key", got, err)
}
}
func TestCreateTicketRejectsDeletedSession(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
+4 -23
View File
@@ -13,10 +13,7 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
_ port.Tracks = (*Store)(nil)
_ port.TrackLookup = (*Store)(nil)
)
var _ port.Tracks = (*Store)(nil)
func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.PreparedTrack, bool, error) {
row, err := s.q.PreparedTrack(ctx, query.PreparedTrackParams{Source: string(t.Source), TrackID: t.ID})
@@ -30,19 +27,6 @@ func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.Prepared
return port.PreparedTrack{Meta: meta, Result: result, MediaKey: orEmpty(row.MediaKey), MediaSize: orZero(row.MediaSize)}, true, nil
}
func (s *Store) TrackMetas(ctx context.Context, ts []track.Track) (map[track.Track]track.Meta, error) {
sources, ids := splitTracks(ts)
rows, err := s.q.TrackMetas(ctx, query.TrackMetasParams{Sources: sources, TrackIds: ids})
if err != nil {
return nil, fmt.Errorf("读取 %d 首歌的元数据:%w", len(ts), err)
}
found := make(map[track.Track]track.Meta, len(rows))
for _, r := range rows {
found[track.Track{Source: track.Source(r.Source), ID: r.TrackID}] = storedMeta(r.Title, orEmpty(r.Artist), orEmpty(r.Album), orEmpty(r.CoverURL), orEmpty(r.CoverKey), r.PageURL, r.DurationMs)
}
return found, nil
}
func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.PreparedTrack) error {
params := query.SavePreparedTrackParams{
Source: string(t.Source),
@@ -69,12 +53,9 @@ func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.Pre
}
func prepared(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64, gain float64, lyric, lyricWords, lyricTrans string) (track.Meta, track.Prepared) {
meta := storedMeta(title, artist, album, coverURL, coverKey, pageURL, durationMs)
return meta, track.Prepared{Duration: meta.Duration, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
}
func storedMeta(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64) track.Meta {
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: time.Duration(durationMs) * time.Millisecond}
d := time.Duration(durationMs) * time.Millisecond
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: d},
track.Prepared{Duration: d, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
}
func orZero(p *int64) int64 {
@@ -1,7 +1,6 @@
package postgres
import (
"maps"
"testing"
"time"
@@ -125,42 +124,3 @@ func TestLinkTrackRoundTrip(t *testing.T) {
t.Errorf("恢复出的条目 = %+v,期望已就绪,元数据取自曲目表", snap.Waiting[0])
}
}
func TestTrackMetas(t *testing.T) {
store, _ := newStore(t)
link := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
saved := map[track.Track]port.PreparedTrack{
netease("1"): {
Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=1", Duration: time.Minute},
Result: track.Prepared{Duration: 61500 * time.Millisecond, Gain: 0.5, Lyric: "词", LyricWords: "逐字", LyricTrans: "翻译"},
},
link: {
Meta: track.Meta{Title: "a.mp3", CoverKey: "link/abc/cover.webp", PageURL: link.ID, Duration: 3 * time.Second},
Result: track.Prepared{Duration: 3 * time.Second, Gain: 1},
MediaKey: "abc",
MediaSize: 12290,
},
}
want := map[track.Track]track.Meta{}
for tr, p := range saved {
if err := store.SavePreparedTrack(t.Context(), tr, p); err != nil {
t.Fatal(err)
}
read, _, err := store.PreparedTrack(t.Context(), tr)
if err != nil {
t.Fatal(err)
}
want[tr] = read.Meta
}
if want[netease("1")].Duration != 61500*time.Millisecond {
t.Fatalf("PreparedTrack 读出的时长 = %v,期望准备得到的时长", want[netease("1")].Duration)
}
wanted := []track.Track{netease("2"), netease("1"), {Source: track.QQ, ID: "1"}, link, netease("1")}
got, err := store.TrackMetas(t.Context(), wanted)
if err != nil || !maps.Equal(got, want) {
t.Errorf("批量读取 = %#v, %v\n期望只有命中的、与 PreparedTrack 读出的元数据一致 %#v:音源和 ID 都要对上,重复的只算一次", got, err, want)
}
if got, err := store.TrackMetas(t.Context(), nil); err != nil || len(got) != 0 {
t.Errorf("什么也不查 = %#v, %v,期望空的结果", got, err)
}
}
+1 -52
View File
@@ -2,34 +2,11 @@ package qq
import (
"context"
"encoding/json"
"errors"
"net/url"
"strings"
"github.com/dihemoe/alisten/internal/app/port"
)
type accountResponse struct {
Code *int `json:"code"`
Req struct {
Code *int `json:"code"`
Data struct {
Identity *struct {
VIP int `json:"vip"`
} `json:"identity"`
SVIP int `json:"svip"`
} `json:"data"`
} `json:"req"`
}
func (r accountResponse) check() error {
if r.Code == nil || r.Req.Code == nil {
return errors.New("QQ 音乐账号响应的 code 或 req.code 缺失或为 null")
}
return nil
}
func (c *Client) CurrentAccount(ctx context.Context) (*port.SourceAccount, error) {
cookie, err := c.cookie.get(ctx)
if err != nil {
@@ -39,35 +16,7 @@ func (c *Client) CurrentAccount(ctx context.Context) (*port.SourceAccount, error
if id == "" {
return nil, nil
}
payload, err := json.Marshal(map[string]any{
"comm": map[string]any{
"ct": 24, "cv": 4747474, "platform": "yqq.json", "uin": id,
"g_tk": 5381, "g_tk_new_20200303": 5381,
"authst": qqAccountCookieValue(cookie, "qm_keyst"),
"format": "json", "inCharset": "utf8", "outCharset": "utf-8", "notice": 0, "needNewCode": 1,
},
"req": map[string]any{"module": "VipLogin.VipLoginInter", "method": "vip_login_base", "param": map[string]any{}},
})
if err != nil {
return nil, err
}
var resp accountResponse
query := url.Values{"data": {string(payload)}}
if err := c.get(ctx, "qq", "account", musicuAPI, query, desktopHeader, &resp); err != nil {
return nil, err
}
// 非零业务码是 QQ 音乐认为登录无效,与网易云的个人资料为空同样处理:没有账号,不算查询失败。
if *resp.Code != 0 || *resp.Req.Code != 0 {
return nil, nil
}
account := &port.SourceAccount{ID: id, Verified: true}
// identity 缺失时会员状态拿不准,留 nil 让界面显示未知。
if resp.Req.Data.Identity != nil {
account.VIP = new(bool)
*account.VIP = resp.Req.Data.Identity.VIP != 0 || resp.Req.Data.SVIP != 0
}
// QQ 音乐的网页接口对自有账号隐藏昵称,拿不到就不显示。
return account, nil
return &port.SourceAccount{ID: id}, nil
}
func qqAccountID(cookie string) string {
+6 -78
View File
@@ -4,39 +4,15 @@ import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
"github.com/dihemoe/alisten/internal/app/port"
)
func accountTestClient(t *testing.T, cookie string, handler http.HandlerFunc) *Client {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cgi-bin/musicu.fcg" {
t.Errorf("意外的请求 %s", r.URL.Path)
http.NotFound(w, r)
return
}
if got := r.Header.Get("Cookie"); got != cookie {
t.Errorf("账号接口的 Cookie = %q,期望已保存的凭据", got)
}
handler(w, r)
}))
t.Cleanup(server.Close)
target, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
return New(
&http.Client{Transport: redirect{target}},
func(context.Context) (string, error) { return cookie, nil },
nil,
(&observed{}).record,
testLogger,
)
func accountTestClient(cookie string, loadErr error) *Client {
load := func(context.Context) (string, error) { return cookie, loadErr }
return New(&http.Client{}, load, nil, (&observed{}).record, testLogger)
}
func TestCurrentAccount(t *testing.T) {
@@ -61,7 +37,7 @@ func TestCurrentAccount(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := accountTestClient(t, tc.cookie, fixture(t, "account.json")).CurrentAccount(t.Context())
got, err := accountTestClient(tc.cookie, nil).CurrentAccount(t.Context())
if err != nil {
t.Fatal(err)
}
@@ -71,7 +47,7 @@ func TestCurrentAccount(t *testing.T) {
}
return
}
want := &port.SourceAccount{ID: tc.wantID, VIP: new(bool), Verified: true}
want := &port.SourceAccount{ID: tc.wantID}
if !reflect.DeepEqual(got, want) {
t.Fatalf("账号资料 = %+v,期望 %+v", got, want)
}
@@ -79,57 +55,9 @@ func TestCurrentAccount(t *testing.T) {
}
}
// 绿钻和豪华绿钻任一有效都算会员,identity 缺失时 VIP 留 nil 显示未知。
func TestCurrentAccountVIP(t *testing.T) {
for _, tc := range []struct {
file string
wantVIP *bool
}{
{"account-vip.json", boolPtr(true)},
{"account-svip.json", boolPtr(true)},
{"account.json", boolPtr(false)},
{"account-no-identity.json", nil},
} {
got, err := accountTestClient(t, "uin=o000123", fixture(t, tc.file)).CurrentAccount(t.Context())
if err != nil {
t.Fatal(err)
}
if got == nil || got.Verified != true || !reflect.DeepEqual(got.VIP, tc.wantVIP) {
t.Errorf("%s 的 VIP = %v,期望 %v", tc.file, got.VIP, tc.wantVIP)
}
}
}
// QQ 音乐认为登录无效时返回 nil 账号而不是错误,与网易云个人资料为空一致。
func TestCurrentAccountExpired(t *testing.T) {
got, err := accountTestClient(t, "uin=o000123", fixture(t, "account-expired.json")).CurrentAccount(t.Context())
if err != nil || got != nil {
t.Fatalf("登录失效时 = %+v, %v,期望 nil 账号且无错误", got, err)
}
}
// 上游异常是查询失败,不能冒充没有账号。
func TestCurrentAccountUpstreamError(t *testing.T) {
for name, handler := range map[string]http.HandlerFunc{
"HTTP 502": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) },
"响应 JSON 畸形": body(`{"code":0,`),
"业务码缺失": body(`{"code":0,"req":{"data":{}}}`),
} {
t.Run(name, func(t *testing.T) {
if _, err := accountTestClient(t, "uin=o000123", handler).CurrentAccount(t.Context()); err == nil {
t.Error("上游异常时没有返回错误")
}
})
}
}
func TestCurrentAccountCookieError(t *testing.T) {
want := errors.New("读取失败")
load := func(context.Context) (string, error) { return "", want }
c := New(&http.Client{}, load, nil, (&observed{}).record, testLogger)
if _, err := c.CurrentAccount(t.Context()); !errors.Is(err, want) {
if _, err := accountTestClient("", want).CurrentAccount(t.Context()); !errors.Is(err, want) {
t.Fatalf("读取凭据的错误 = %v,期望 %v", err, want)
}
}
func boolPtr(b bool) *bool { return &b }
-60
View File
@@ -1,60 +0,0 @@
package qq
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/dihemoe/alisten/internal/app/port"
)
const playlistDetailAPI = "https://i.y.qq.com/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg"
type playlistDetailResponse struct {
Code int `json:"code"`
Cdlist []struct {
Songlist []struct {
SongMID string `json:"songmid"`
SongName string `json:"songname"`
AlbumName string `json:"albumname"`
AlbumMID string `json:"albummid"`
Interval int64 `json:"interval"`
Singer []singer `json:"singer"`
} `json:"songlist"`
} `json:"cdlist"`
}
func (r playlistDetailResponse) check() error {
if r.Code != 0 {
return fmt.Errorf("QQ 音乐返回错误码 %d", r.Code)
}
return nil
}
func (c *Client) PlaylistTracks(ctx context.Context, playlistID string) ([]port.Song, error) {
return cached(c, "playlist_tracks "+playlistID, func() ([]port.Song, error) {
var resp playlistDetailResponse
query := url.Values{
"type": {"1"}, "json": {"1"}, "utf8": {"1"}, "onlysong": {"0"}, "disstid": {playlistID},
"format": {"json"}, "g_tk": {"5381"}, "loginUin": {"0"}, "hostUin": {"0"},
"inCharset": {"utf8"}, "outCharset": {"utf-8"}, "notice": {"0"}, "platform": {"yqq"}, "needNewCode": {"0"},
}
if err := c.get(ctx, "qq", "playlist_tracks", playlistDetailAPI, query, desktopHeader, &resp); err != nil {
return nil, err
}
if len(resp.Cdlist) == 0 {
return nil, errors.New("QQ 音乐的歌单不存在或不可见")
}
// 这个接口一次返回全部曲目,实测 1137 首的歌单也完整,不需要翻页。
songs := make([]port.Song, 0, len(resp.Cdlist[0].Songlist))
for _, s := range resp.Cdlist[0].Songlist {
// 没有 songmid 的条目是下架等不可用的歌,丢弃但保持其余歌曲的原顺序。
if s.SongMID == "" {
continue
}
songs = append(songs, song(s.SongMID, s.SongName, s.Singer, s.AlbumName, s.AlbumMID, s.Interval))
}
return songs, nil
})
}
-69
View File
@@ -1,69 +0,0 @@
package qq
import (
"net/http"
"reflect"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
func TestPlaylistTracks(t *testing.T) {
c, obs := serve(t, map[string]http.HandlerFunc{
"/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg": func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("disstid"); got != "7382629476" {
t.Errorf("歌单 ID = %q,期望与搜索返回的 dissid 一致", got)
}
fixture(t, "playlist-tracks.json")(w, r)
},
})
want := []port.Song{
{
Track: track.Track{Source: track.QQ, ID: "mid1"},
Meta: track.Meta{
Title: "第一首", Artist: "歌手甲, 歌手乙", Album: "专辑一",
CoverURL: "https://y.gtimg.cn/music/photo_new/T002R300x300M000album1.jpg",
PageURL: "https://y.qq.com/n/ryqq/songDetail/mid1",
Duration: 200 * time.Second,
},
},
{
Track: track.Track{Source: track.QQ, ID: "mid2"},
Meta: track.Meta{
Title: "第二首", Artist: "歌手丙", Album: "专辑二",
CoverURL: "https://y.gtimg.cn/music/photo_new/T002R300x300M000album2.jpg",
PageURL: "https://y.qq.com/n/ryqq/songDetail/mid2",
Duration: 100 * time.Second,
},
},
}
// 缓存命中时第二次调用不再发请求。
for range 2 {
got, err := c.PlaylistTracks(t.Context(), "7382629476")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("歌单内的歌曲 = %#v\n期望 %#v", got, want)
}
}
obs.check(t, "qq playlist_tracks")
}
func TestPlaylistTracksUnavailable(t *testing.T) {
for name, file := range map[string]string{
"接口返回错误码": "playlist-tracks-missing.json",
"cdlist 为空": "playlist-tracks-empty.json",
} {
t.Run(name, func(t *testing.T) {
c, _ := serve(t, map[string]http.HandlerFunc{
"/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg": fixture(t, file),
})
if _, err := c.PlaylistTracks(t.Context(), "999999999999"); err == nil {
t.Error("不存在或不可见的歌单没有返回错误")
}
})
}
}
-1
View File
@@ -1 +0,0 @@
{"code":0,"req":{"code":301,"data":{}}}
@@ -1 +0,0 @@
{"code":0,"req":{"code":0,"data":{}}}
-1
View File
@@ -1 +0,0 @@
{"code":0,"req":{"code":0,"data":{"identity":{"vip":0},"svip":1}}}
-1
View File
@@ -1 +0,0 @@
{"code":0,"req":{"code":0,"data":{"identity":{"vip":1},"svip":0}}}
-1
View File
@@ -1 +0,0 @@
{"code":0,"req":{"code":0,"data":{"identity":{"vip":0},"svip":0}}}
@@ -1 +0,0 @@
{"code":0,"subcode":0,"cdnum":0,"cdlist":[]}
@@ -1 +0,0 @@
{"code":10,"subcode":0,"cdnum":0,"cdlist":[]}
-1
View File
@@ -1 +0,0 @@
{"code":0,"subcode":0,"cdnum":1,"cdlist":[{"dissname":"测试歌单","songlist":[{"songmid":"mid1","songname":"第一首","albumname":"专辑一","albummid":"album1","interval":200,"singer":[{"name":"歌手甲"},{"name":"歌手乙"}]},{"songmid":"","songname":"已下架的歌","albumname":"","albummid":"","interval":0,"singer":[]},{"songmid":"mid2","songname":"第二首","albumname":"专辑二","albummid":"album2","interval":100,"singer":[{"name":"歌手丙"}]}]}]}
+6 -6
View File
@@ -15,8 +15,8 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
// 任务载荷是跨版本的契约,新版本必须能执行旧版本留下的任务。载荷包含曲目的 Source 和 ID。
// 适配器之间不能互相 import,所以 internal/adapter/in/worker 要按相同的 kind 和字段解码。
// 任务载荷是跨版本契约:新版本必须能执行旧版本留下的任务。载荷包含曲目的 Source 和 ID。
// 适配器之间不能互相 import,internal/adapter/in/worker 按相同的 kind 和字段解码。
type prepareArgs struct {
Source string `json:"source"`
ID string `json:"id"`
@@ -35,7 +35,7 @@ func (a prepareArgs) InsertOpts() river.InsertOpts {
return river.InsertOpts{
Queue: queueOf(track.Source(a.Source)),
MaxAttempts: 2,
// 去重不包括已完成的任务,因为曲目表里的结果被清除之后,River 保留的已完成任务不能阻止重新准备。
// 去重不包括已完成的任务:曲目表中的结果被清除之后,River 保留的已完成任务不能阻止重新准备。
UniqueOpts: river.UniqueOpts{
ByArgs: true,
ByState: []rivertype.JobState{
@@ -46,7 +46,7 @@ func (a prepareArgs) InsertOpts() river.InsertOpts {
}
}
// 链接和 B 站的歌由 worker 准备,因为只有 worker 的镜像里有 yt-dlp 和 ffmpeg。
// 链接和 B 站的歌由 worker 准备:只有 worker 的镜像里有 yt-dlp 和 ffmpeg。
func queueOf(source track.Source) string {
switch source {
case track.Link:
@@ -75,7 +75,7 @@ func (q *Queue) Submit(ctx context.Context, t track.Track) (int64, error) {
return res.Job.ID, nil
}
// 已被 River 清理的任务当作已完成,因为成功的结果在曲目表里,失败的任务也不会再执行。
// 已被 River 清理的任务当作已完成:成功的结果在曲目表中,失败的任务不会再执行。
func (q *Queue) Status(ctx context.Context, jobID int64) (port.JobState, string, error) {
job, err := q.client.JobGet(ctx, jobID)
if errors.Is(err, river.ErrNotFound) {
@@ -95,7 +95,7 @@ func (q *Queue) Status(ctx context.Context, jobID int64) (port.JobState, string,
return port.JobPending, "", nil
}
// River 记录取消的原因时带有 "JobCancelError: " 前缀。internal/adapter/in/worker 用同样的方式提取任务失败的原因。
// River 记录取消的原因时带有 "JobCancelError: " 前缀;internal/adapter/in/worker 以同样的方式提取任务失败的原因。
func reason(job *rivertype.JobRow) string {
if len(job.Errors) == 0 {
return "准备任务失败"
+11 -11
View File
@@ -31,13 +31,13 @@ const (
membersChannel = "members"
preparedChannel = "tracks.prepared"
roomPrefix = "room:"
// 订阅连接空闲这么久就发一次 PING,再过这么久还没有回复就换一个新连接。
// 订阅连接空闲这么久发一次 PING,再过这么久仍无回复就换一个新连接:
// 半开的 TCP 连接读不到任何错误,只能这样发现。
pingInterval = 5 * time.Second
reconnectDelay = time.Second
// 订阅连接上的写入不使用调用方的期限。go-redis 会把写超时当作连接已坏,重建共用的订阅连接,本实例的全部订阅随之中断。
// 而调用方可能在 go-redis 内部的锁上一直等到自己的期限用完才开始写,比如 go-redis 正持锁重新连接的时候。
// go-redis 从真正开始写的时刻按 WriteTimeout 计算写入的期限,sharedWriteTimeout 只是兜底,要长于持锁重新连接的时间。
// 订阅连接上的写入不使用调用方的期限:go-redis 把写超时当作连接已坏而重建共用的订阅连接,本实例的全部订阅随之中断;
// 而调用方可能在 go-redis 内部的锁上一直等到自己的期限用完才开始写,例如 go-redis 正持锁重新连接时。
// go-redis 从真正开始写的时刻按 WriteTimeout 计算写入的期限,这个上限只是兜底,要长于持锁重新连接的时间:
// 默认的连接、读、写超时各 5 秒,持锁重新连接一次最长约 20–30 秒。
sharedWriteTimeout = 30 * time.Second
)
@@ -56,7 +56,7 @@ type Bus struct {
dropped *prometheus.CounterVec
done chan struct{}
// 持有 mu 时不调用可能等网络的 go-redis 方法,因为 Receive 处理 PONG、报告中断都要拿 mu。
// 持有 mu 时不调用可能等网络的 go-redis 方法:Receive 处理 PONG、报告中断都要 mu。
mu sync.Mutex
closed bool
ps *goredis.PubSub
@@ -101,7 +101,7 @@ func (b *Bus) SubscribeRoom(ctx context.Context, roomID string) error {
return b.subscribe(ctx, roomChannel(roomID))
}
// Redis 按顺序处理一个连接上的命令,所以订阅之后在同一个连接上发一次 PING,收到 PING 的回复时订阅已经生效。
// Redis 按顺序处理一个连接上的命令:订阅之后在同一个连接上发一次 PING,收到它的回复时订阅已经生效。
func (b *Bus) subscribe(ctx context.Context, channels ...string) error {
if err := ctx.Err(); err != nil {
return fmt.Errorf("订阅 %s:%w", strings.Join(channels, "、"), err)
@@ -162,7 +162,7 @@ func writeShared(ctx context.Context, write func(context.Context) error) error {
}
w, cancel := context.WithTimeout(context.WithoutCancel(ctx), sharedWriteTimeout)
defer cancel()
// 调用方结束时取消 w。go-redis 只按 ctx 的期限设置读写超时,不理会取消,所以已有连接上的写入不受影响。取消只打断建立连接,
// 调用方结束时取消 w:go-redis 只按 ctx 的期限设置读写超时而不理会取消,所以已有连接上的写入不受影响;取消只打断建立连接,
// 以免调用方持着 go-redis 的锁等到连接超时,拖住排在后面的调用方。
stop := context.AfterFunc(ctx, cancel)
defer stop()
@@ -228,8 +228,8 @@ func (b *Bus) publish(ctx context.Context, channel, kind string, data json.RawMe
return receivers, nil
}
// 连接出错时,go-redis 会自行重新连接并重新订阅全部频道,但断开期间的消息已经丢失。连接只是停止响应时 go-redis 察觉不到,
// 由 Receive 换一个新连接。两种情况都在恢复订阅之前先报告 SubscriptionLost,让调用方尽早释放房间、断开会话。
// 连接出错时 go-redis 会自行重新连接并重新订阅全部频道,但断开期间的消息已经丢失;连接只是停止响应时 go-redis 无法察觉,
// 由这里换一个新连接。两种情况都在恢复订阅之前先报告 SubscriptionLost,让调用方尽早释放房间、断开会话。
func (b *Bus) Receive(ctx context.Context) (port.Delivery, error) {
stop := context.AfterFunc(ctx, b.close)
defer stop()
@@ -294,7 +294,7 @@ func timeout(err error) bool {
return errors.As(err, &ne) && ne.Timeout()
}
// replace 不做 I/O。Receive 先报告 SubscriptionLost,下一次接收时再连接并订阅原来的频道,这样停止响应的服务端不会拖延报告。
// 这里不做 I/O:先报告 SubscriptionLost,下一次接收时再连接并订阅原来的频道,停止响应的服务端不会拖延报告。
func (b *Bus) replace(old *goredis.PubSub) {
b.mu.Lock()
if b.closed || b.ps != old {
@@ -386,7 +386,7 @@ func (b *Bus) delivery(channel string, e envelope) (port.Delivery, error) {
return nil, fmt.Errorf("频道 %s 上不应有 %s 消息", channel, e.Kind)
}
// 带有追踪上下文的输入延续网关的 trace。received 记录一个 consumer span,owner 处理这条输入时的调用都在这个 span 之下。
// 带有追踪上下文的输入延续网关的 trace:记录一个 consumer span,owner 处理这条输入时的调用都在它之下。
// 这个 span 只标记收到输入的时刻,处理的耗时由它之下的调用体现。
func (b *Bus) received(carrier map[string]string, roomID string) context.Context {
ctx := propagation.TraceContext{}.Extract(context.Background(), propagation.MapCarrier(carrier))
+6 -7
View File
@@ -302,7 +302,7 @@ func TestDroppedInputRecordsNoSpan(t *testing.T) {
if err := client.Publish(t.Context(), instanceChannel(b.instance), payload).Err(); err != nil {
t.Fatal(err)
}
// 同一个频道上的消息按顺序处理,所以收到后面这条有效的输入时,前面那条已经被丢弃了。
// 同一个频道上的消息按顺序处理:收到后面这条有效的输入时,前面那条已经丢弃了。
valid := port.RoomInput{Room: randomRoom(), Gateway: a.instance, Input: room.Join{Session: "a/s1", Member: alice}, Takeover: true}
if ok, err := a.bus.SendInput(t.Context(), b.instance, valid); err != nil || !ok {
t.Fatalf("SendInput = %v, %v", ok, err)
@@ -373,7 +373,7 @@ func TestUnsubscribeRoom(t *testing.T) {
if err := b.bus.UnsubscribeRoom(t.Context(), gone); err != nil {
t.Fatal(err)
}
// 退订不等服务端确认。同一个连接上的命令按顺序处理,所以之后的订阅生效时,退订也已经生效。
// 退订不等服务端确认。同一个连接上的命令按顺序处理,之后的订阅生效时,退订已经生效。
if err := b.bus.SubscribeRoom(t.Context(), kept); err != nil {
t.Fatal(err)
}
@@ -519,7 +519,7 @@ func TestSubscriptionLostAfterDisconnect(t *testing.T) {
func TestSubscriptionLostWhenConnectionStalls(t *testing.T) {
a, b := newBus(t, newClient(t), "v1"), newBus(t, newClient(t), "v1")
roomID := randomRoom()
// 接收循环的超时从它收到订阅确认时算起,这个时刻早于 CLIENT PAUSE 返回,所以从订阅之前就开始计时。
// 接收循环的超时从它收到订阅确认时算起,早于 CLIENT PAUSE 返回,所以从订阅之前开始计时。
start := time.Now()
if err := b.bus.SubscribeRoom(t.Context(), roomID); err != nil {
t.Fatal(err)
@@ -579,7 +579,7 @@ func TestLateCallerDoesNotBreakTheSharedConnection(t *testing.T) {
}
}
// Freeze 之后,已有的连接保持打开,但收到的字节全部丢弃,不再转发。
// Freeze 之后,已有的连接保持打开,但收到的字节全部丢弃,不再转发:
// 对客户端来说这是半开的 TCP 连接,写得出去,读不到错误,也等不到回复。Freeze 之后新建的连接照常转发。
type freezeProxy struct {
listener net.Listener
@@ -646,8 +646,7 @@ func (p *freezeProxy) Freeze() {
}
}
// 此后新建的连接都收不到任何回复,直到调用返回的 release。接收循环停在第一个新连接上,held 只在这时通知一次。
// 后面的连接留在 accept 队列里,不会再通知。
// 此后新建的连接在建立后停住,客户端收不到任何回复,直到调用返回的 release;每停住一个连接,held 通知一次。
func (p *freezeProxy) HoldNew() (held <-chan struct{}, release func()) {
p.mu.Lock()
defer p.mu.Unlock()
@@ -840,7 +839,7 @@ func TestReplaceDoesNotWaitForWriterOnOldSubscription(t *testing.T) {
t.Fatal("订阅没有建立连接")
}
// ReceiveTimeout 也要拿 old 的锁。从外部没法安排写入恰好在接收循环最后一次接收之后拿到锁,所以直接调用 replace。
// ReceiveTimeout 也要 old 的锁,无法从外部安排写入恰好在接收循环最后一次接收之后取得锁,所以直接调用 replace。
replaced := make(chan struct{})
go func() {
bus.replace(old)
+1 -1
View File
@@ -65,7 +65,7 @@ const (
inputCommand = "Command"
)
// 接口的实现必须先登记才能编码。实现的字段里不能再有接口类型。
// 接口实现必须登记后才能编码;实现字段中不能再有接口类型。
type union[T any] map[string]reflect.Type
func newUnion[T any](variants ...T) union[T] {
Loaded 100 of 455 files, more files were not shown because too many files have changed in this diff. Show more