Compare commits

..
10 Commits
Author SHA1 Message Date
DiheChen dab7e2f46b feat: verify the QQ Music account with QQ Music
CI / go (push) Waiting to run
CI / web (push) Waiting to run
CI / images (Dockerfile, server, alisten) (push) Waiting for needed jobs
CI / images (web/Dockerfile, web, ) (push) Waiting for needed jobs
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Waiting for needed jobs
CI / helm-release (push) Waiting for needed jobs
CI / deploy (push) Waiting for needed jobs
2026-11-11 00:31:38 +08:00
DiheChen 6f286c2eb2 feat: open and import QQ Music playlists
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-11 00:30:04 +08:00
DiheChen 19a135c983 feat: read the songs in QQ Music playlists 2026-11-11 00:28:21 +08:00
DiheChen 52c4aaa9d3 fix: reject array fields in GET and DELETE room requests
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-11 00:26:47 +08:00
DiheChen b986abf937 docs: reword comments and protocol descriptions for readability 2026-11-11 00:25:12 +08:00
DiheChen d72b4203cb feat: browse stored media page by page, sort and search it, and delete several songs at once
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-11 00:23:50 +08:00
DiheChen 0f6ecaf6da feat: organize settings into sections that apply changes immediately 2026-11-11 00:22:33 +08:00
DiheChen 8cacfb68ff feat: organize the admin page into sections with room search, paging and source status cards 2026-11-11 00:21:31 +08:00
DiheChen c7a5c44b1c feat: add sectioned page layout, switches, segmented controls, paging and neutral avatars 2026-11-11 00:20:26 +08:00
DiheChen fb751ca57f feat: page, sort and search stored media and delete several at once in the admin API 2026-11-11 00:19:07 +08:00
391 changed files with 7017 additions and 2575 deletions

No files matched your search

+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 哈希;纯计算,不做 I/O。
# 用于房间密码和账号密码的 argon2id 哈希。argon2 是纯计算,不做 I/O。
- golang.org/x/crypto/argon2
# 用于合并同一首歌的并发播放地址解析;只协调 goroutine,不做 I/O。
# 用于合并同一首歌的并发播放地址解析。singleflight 只协调 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 才不会经由它依赖 OpenTelemetry。
# app 通过 logctx 记录日志的关联字段。logctx 只依赖标准库,这样 app 才不会经由 logctx 依赖 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:
+8 -8
View File
@@ -150,7 +150,7 @@ func parseIDL(data []byte) (*idl, error) {
return &d, nil
}
// encoding/json 对重复的键只保留最后一个值,并把显式的 null 解码为 nil,而之后的检查和生成都假定节点存在,所以解码之前先拒绝这两种写法。
// encoding/json 对重复的键只保留最后一个值,还会把显式的 null 解码为 nil。之后的检查和生成都假定节点存在,所以解码之前先拒绝重复的键和显式的 null。
// 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 d.jsonType(m.Request.Properties[name]) == "object" {
if t := d.jsonType(m.Request.Properties[name]); t != "string" && t != "integer" {
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 三种。
// 请求的字段已经检查过,不会是 null、布尔或数字,所以只有 string、integer、object 和 array 四种。
func (d *idl) jsonType(s *schema) string {
if s.Ref != "" {
return d.Defs[refName(s.Ref)].Type
+5
View File
@@ -246,6 +246,11 @@ 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
+3 -2
View File
@@ -75,8 +75,9 @@ func generateFrom(path string) ([]output, error) {
return generate(d)
}
// 生成函数只处理 check 接受过的 IDL,直接依赖它保证的结构,不再检查:引用都指向存在的类型(标量类型已经展开),数组都有 items,
// anyOf 是 [类型, {"type": "null"}],allOf 的每个条件只按必填的枚举字段判断,需要解码函数的类型都已标记在 inbound 中。
// 生成函数只处理 check 接受过的 IDL,所以直接依赖 check 保证的结构,不再重复检查。check 保证了以下几点:
// 引用都指向存在的类型(标量类型已经展开),数组都有 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
+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;落盘以它为条件,因此过期的 owner 无法写入。
-- 每次接管房间时加 1。落盘以 term 为条件,所以过期的 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(),
-- 第一次生成 API Key 时创建,此后重置 Key、修改密码都不换。
-- bot 成员在第一次生成 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()
);
-- 至多一行;没有这一行时品牌名是默认的 Alisten。
-- site 表至多一行。没有这一行时,品牌名是默认的 Alisten。
CREATE TABLE site (
id boolean PRIMARY KEY DEFAULT true CHECK (id),
brand_name text NOT NULL
+2 -2
View File
@@ -16,7 +16,7 @@ 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,
@@ -24,7 +24,7 @@ ALTER TABLE members
ADD COLUMN email text,
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL);
-- site 表没有行或者 brand_name 为 NULL 时,品牌名是默认的 Alisten;没有行时开放注册。
-- site 表没有行或者 brand_name 为 NULL 时,品牌名是默认的 Alisten。site 表没有行时开放注册。
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,
+1 -1
View File
@@ -2,7 +2,7 @@
-- 关闭之后不再记录新的播放历史,已有的保留。
ALTER TABLE members ADD COLUMN record_listens boolean NOT NULL DEFAULT true;
-- 成员在房间里听到过的歌,每首只留最近一次。元数据存一份快照,不关联 tracks:管理员删除已存储的媒体时会连带删掉 tracks 中对应的行。
-- 成员在房间里听到过的歌,每首只留最近一次。元数据存一份快照,不关联 tracks,因为管理员删除已存储的媒体时会连带删掉 tracks 中对应的行。
CREATE TABLE listens (
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
source text NOT NULL,
+1 -1
View File
@@ -1,3 +1,3 @@
-- +goose Up
-- 分享链接中的随机串,NULL 表示没有分享。明文保存:所有者随时可以再复制一次链接。
-- 分享链接中的随机串,NULL 表示没有分享。明文保存,这样所有者随时可以再复制一次链接。
ALTER TABLE playlists ADD COLUMN share_id text UNIQUE;
+1 -1
View File
@@ -1,5 +1,5 @@
-- +goose Up
-- 绑定了 GitHub 的成员也是已注册的账号,可以没有用户名和密码;账号的邮箱因此也可以属于只绑定了 GitHub 的成员。
-- 绑定了 GitHub 的成员也是已注册的账号,可以没有用户名和密码。所以账号的邮箱也可以属于只绑定了 GitHub 的成员。
ALTER TABLE members
ADD COLUMN github_id bigint UNIQUE,
DROP CONSTRAINT members_email_check,
+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 (
+4 -4
View File
@@ -1,6 +1,6 @@
-- name: LockListeners :many
-- 按 ID 的顺序锁住开启了记录的成员:同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删;
-- 加锁的顺序固定,同时写几个成员的事务之间不会死锁。
-- 按 ID 的顺序锁住开启了记录的成员。这样同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删。
-- 加锁的顺序固定,所以同时写几个成员的事务之间不会死锁。
SELECT id FROM members WHERE id = ANY(@ids::uuid[]) AND record_listens ORDER BY id FOR NO KEY UPDATE;
-- name: UpsertListens :exec
@@ -14,7 +14,7 @@ SET title = excluded.title, artist = excluded.artist, album = excluded.album, co
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: MoveListens :exec
-- 并入的规则与 UpsertListens 相同,并入的那份算新写入的;to_id 关闭了记录时只删不并。
-- 并入的规则与 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
@@ -29,7 +29,7 @@ SET title = excluded.title, artist = excluded.artist, album = excluded.album, co
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: TrimListens :exec
-- 排序与 MemberListens 相同:留下的正是读取时返回的那些。
-- 排序与 MemberListens 相同,所以留下的正是读取时返回的那些。
DELETE FROM listens l
USING (
SELECT member_id, source, track_id,
+13 -4
View File
@@ -1,10 +1,19 @@
-- name: ListStoredMedia :many
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL
ORDER BY prepared_at DESC, track_id;
-- 曲目 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 '\';
-- name: StoredMedia :one
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
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 = @media_key::text;
-- name: DeleteStoredMedia :exec
+7 -7
View File
@@ -9,7 +9,7 @@ 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,
@@ -21,12 +21,12 @@ RETURNING *;
SELECT username, email, github_id FROM members WHERE id = @id;
-- name: RegisterMember :execrows
-- 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
-- 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码时还要删除其他会话,所以用 AddMemberCredentials。
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 的账号设置,它的“我喜欢”已经在绑定时创建了。
-- 只给只绑定了 GitHub 的账号设置。这种账号的“我喜欢”已经在绑定时创建了。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL AND github_id IS NOT NULL;
@@ -40,7 +40,7 @@ SELECT * FROM members WHERE id = @id AND (username IS NOT NULL OR github_id IS N
SELECT * FROM members WHERE github_id = @github_id;
-- name: BindGitHub :one
-- 只给还没有绑定 GitHub 的成员绑定,已经绑定过的成员不受影响,没有返回的行。was_guest 表示成员原来是游客:这次绑定就是注册。
-- 只给还没有绑定 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;
@@ -53,15 +53,15 @@ UPDATE members SET github_id = NULL WHERE id = @id AND password_hash IS NOT NULL
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:同一个账号的几次登录依次并入游客的历史,否则两边会按相反的顺序抢同一批历史行而死锁。
-- 这把锁让修改密码等到这次登录提交之后才进行,所以修改密码随后删除会话时,会删掉这次登录签发的会话。
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE,这样同一个账号的几次登录会依次并入游客的历史。否则两次登录会按相反的顺序抢同一批历史行而死锁。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR NO KEY UPDATE;
-- name: DeleteSession :execrows
+4 -4
View File
@@ -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
@@ -71,7 +71,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)
@@ -119,7 +119,7 @@ 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
+1 -1
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
+1 -1
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
+8 -10
View File
@@ -76,7 +76,7 @@ type call struct {
member member.Member
roomID string
ticket string
// 票据签发给的成员会话的 token 哈希,API Key 没有会话。
// 持有这张票据的成员会话的 token 哈希。API Key 没有会话。
memberSession []byte
// 票据是常驻房间的 API Key。
bot bool
@@ -160,7 +160,7 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
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.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminRemoveMedia, srv.adminRemoveMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
srv.handle(mux, protocol.RouteAdminOpenRegistration, srv.adminOpenRegistration)
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
@@ -185,14 +185,14 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
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,8 +232,8 @@ 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 {
@@ -297,8 +297,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,9 +326,7 @@ 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},
+1 -1
View File
@@ -35,7 +35,7 @@ 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")
+2 -2
View File
@@ -7,8 +7,8 @@ import (
)
// 只有对端是回环、私有网段或链路本地地址的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理,
// 第一个不受信任的地址就是客户端;全部受信任时取最左端的地址。受信任的代理只会写入合法的地址,
// 遇到不合法的值说明已经到了客户端自己填写的部分,此时改用对端地址。
// 第一个不受信任的地址就是客户端。如果全都受信任,就取最左端的地址。受信任的代理只会写入合法的地址,
// 所以遇到不合法的值,说明已经到了客户端自己填写的部分,这时改用对端地址。
func clientIP(r *http.Request) string {
peer, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
+4 -4
View File
@@ -24,7 +24,7 @@ var roomErrorCodes = map[room.ErrorKind]protocol.ErrorCode{
room.NothingPlaying: protocol.ErrorCodeNothingPlaying,
}
// bot 表示 by 是常驻房间的 bot 成员。返回的成员是房间命令的发起人,bot 代填了点歌人时换成那个名字。
// bot 表示 by 是常驻房间的 bot 成员。返回的成员是房间命令的发起人。如果 bot 代填了点歌人,返回的成员 ID 不变,名字换成点歌人的名字加上“(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)
@@ -238,7 +238,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
+7 -7
View File
@@ -15,9 +15,9 @@ import (
)
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 看到它就该停下,而不是重连之后互相顶掉。
// 不用 1012,因为共用一把 Key 的两个 bot 收到 4409 就该停下,而不是重连之后互相顶掉。
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 的握手相同,打开之后的推送不设期限。
@@ -91,7 +91,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 +116,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 {
+5 -5
View File
@@ -150,7 +150,7 @@ func TestRoomEvents(t *testing.T) {
})
}
// 网关定期到存储中核对连接所用的凭证:事件流登记的凭证要与存储中的票据哈希相同,否则仍然有效的事件流也会被断开。
// 网关定期到存储里核对连接所用的凭证。事件流登记的凭证要和存储里的票据哈希相同,否则仍然有效的事件流也会被断开。
func TestRoomEventsSurviveCredentialCheck(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
@@ -167,7 +167,7 @@ func TestRoomEventsSurviveCredentialCheck(t *testing.T) {
bot, member := f.stream(t, key), f.stream(t, ticket)
readEvent(t, bot)
readEvent(t, member)
// 心跳每 25 秒一次,核对每 30 秒一次:第二次心跳之前已经核对过一次。
// 心跳每 25 秒一次,核对每 30 秒一次,所以第二次心跳之前已经核对过一次。
for range 2 {
checkEvent(t, bot, ": ping")
checkEvent(t, member, ": ping")
@@ -298,7 +298,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 +322,7 @@ func TestRoomEventsOpeningTimesOut(t *testing.T) {
})
}
// 第二次查找票据,即事件流生效之后的确认,发生 panic。
// 第二次查找票据时发生 panic,也就是事件流生效之后的那次确认。
type panickingRecheck struct {
*porttest.Store
lookups atomic.Int32
@@ -369,7 +369,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)
}
+7 -7
View File
@@ -10,12 +10,12 @@ import (
"github.com/dihemoe/alisten/protocol"
)
// 用户在 GitHub 上授权之后,浏览器跳到这里:网页的 origin 加上它,就是 OAuth App 中登记的回调地址。
// 用户在 GitHub 上授权之后,浏览器跳到这个路径。网页的 origin 加上 GitHubCallbackPath,就是 OAuth App 里登记的回调地址。
const GitHubCallbackPath = "/api/auth/github/callback"
// 回调处理完之后的落地页,结果放在片段里。App 用系统浏览器授权,跳到 App 的自定义 scheme 才能回到 App。
// 自定义 scheme 可能被别的 App 抢注,截获的兑换码却换不到身份:它 1 分钟内有效、只能用一次,兑换时要带上发起授权的客户端本机保存的
// nonce,并且必须由发起授权的同一个成员调用。
// 回调处理完之后的落地页,结果放在片段里。App 用系统浏览器授权,要跳到 App 的自定义 scheme 才能回到 App。
// 自定义 scheme 可能被别的 App 抢注,但截获的兑换码换不到身份。兑换码 1 分钟内有效,只能用一次。兑换时要带上发起授权的客户端在本机保存的
// nonce,而且必须由发起授权的同一个成员调用。
const (
githubWebLanding = "/auth/github"
githubAppLanding = "moe.dihe.alisten:/auth/github"
@@ -88,8 +88,8 @@ func (s *server) adminDeleteGithubCredential(ctx context.Context, _ call) (any,
return nil, s.GitHub.DeleteOAuthApp(ctx)
}
// 浏览器从 GitHub 跳回来,换到兑换码之后跳到发起授权的客户端的落地页。结果放在片段里,不会出现在之后的请求和 Referer 中;
// 这个地址的查询串里有授权码,所以响应也不让浏览器把它作为 Referer 发出去。跳转目标只看 state 对应的记录,不看回调地址中的参数,
// 浏览器从 GitHub 跳回来,换到兑换码之后跳到发起授权的客户端的落地页。结果放在片段里,不会出现在之后的请求和 Referer 里。
// 回调地址的查询串里有授权码,所以响应也不让浏览器把回调地址当作 Referer 发出去。跳转目标只看 state 对应的记录,不看回调地址里的参数,
// 否则就成了开放重定向。
func (s *server) githubCallback(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
@@ -134,7 +134,7 @@ func (s *server) githubCallback(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
// 不用 http.Redirect:它会把片段当作路径的一部分清理。
// 不用 http.Redirect,因为 http.Redirect 会把片段当作路径的一部分清理掉。
w.Header().Set("Location", landing+"#"+fragment)
w.WriteHeader(http.StatusFound)
}
+49 -15
View File
@@ -3,10 +3,9 @@ package api
import (
"context"
"net/http"
"regexp"
"time"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
"github.com/dihemoe/alisten/protocol"
)
@@ -43,33 +42,68 @@ func (s *server) redirectTarget(ctx context.Context, r *http.Request, t track.Tr
return s.Playback.URL(ctx, t, time.Now())
}
var mediaKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
var mediaSorts = map[protocol.MediaSort]port.MediaSort{
protocol.MediaSortStored: port.MediaByStored,
protocol.MediaSortIdle: port.MediaByIdle,
protocol.MediaSortSize: port.MediaBySize,
}
func (s *server) adminListMedia(ctx context.Context, _ call) (any, error) {
list, err := s.Media.List(ctx)
func (s *server) adminListMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminListMediaRequest(c.body)
if err != nil {
return nil, err
}
items := make([]protocol.StoredMedia, len(list))
for i, m := range list {
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 {
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, Size: m.Size, StoredAtMs: m.StoredAt.UnixMilli()}
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(),
}
if m.LastPlayedAt != nil {
played := m.LastPlayedAt.UnixMilli()
items[i].LastPlayedAtMs = &played
}
}
return protocol.AdminListMediaResponse{Items: items}, nil
return protocol.AdminListMediaResponse{Items: items, Total: int64(page.Total), TotalBytes: page.TotalBytes, Page: int64(q.Page), PageSize: port.MediaPageSize}, nil
}
func (s *server) adminDeleteMedia(ctx context.Context, c call) (any, error) {
key := c.r.PathValue("key")
if !mediaKeyPattern.MatchString(key) {
return nil, media.ErrNotFound
func (s *server) adminRemoveMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminRemoveMediaRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Media.Delete(ctx, key)
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
}
+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)
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)},
}}
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)},
)
store := porttest.New()
s := services(store, store, "admin-secret")
s.Media = media.NewLibrary(stored, stored, porttest.NewMediaStore())
s.StorageURL = "https://alisten.example/storage/alisten"
s.Media = media.NewLibrary(library, inRooms{{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}}, porttest.NewMediaStore())
server, _ := start(t, s)
admin := []string{"Authorization", "Bearer admin-secret"}
want := response{status: http.StatusOK, body: `{"items":[` +
`{"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)
`{"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)
}
}
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)
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 = false
resp, header := exchange(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...)
checkNoContent(t, "删除媒体", resp, header)
}
type playURL map[string]string
+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:如果先取消,正在读取第一帧的 Read 会直接断开连接,
// 客户端收不到 4408。
// 超时时先以 4408 关闭连接,再取消 ctx。如果先取消 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
}
+3 -3
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
}
@@ -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 的字节数就停止并正常退出。多一个字节,截断的输出才一定超过上限,由准备流程拒绝。
// ffmpeg 写到不少于 -fs 的字节数就停止,并且正常退出。-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)))
@@ -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 -1
View File
@@ -31,7 +31,7 @@ func (s *Store) GitHubOAuthApp(ctx context.Context) (port.GitHubOAuthApp, error)
return port.GitHubOAuthApp{}, fmt.Errorf("读取 GitHub OAuth App:%w", err)
}
var app githubOAuthApp
// 不包装解码的错误:其中可能带有原值的片段,也就可能带有 Client Secret。
// 不包装解码的错误,因为解码的错误里可能带有原值的片段,也就可能带有 Client Secret。
if err := json.Unmarshal([]byte(value), &app); err != nil || app.ClientID == "" || app.ClientSecret == "" {
return port.GitHubOAuthApp{}, errors.New("凭据表中 GitHub OAuth App 的格式不正确")
}
@@ -27,7 +27,7 @@ func TestGitHubOAuthApp(t *testing.T) {
}
}
// 并发地保存和读取:读到的 Client ID 和 Secret 总是同一次保存的。
// 并发地保存和读取。读到的 Client ID 和 Secret 总是同一次保存的。
var wg sync.WaitGroup
for i := range 4 {
wg.Go(func() {
@@ -72,7 +72,7 @@ func TestGitHubOAuthApp(t *testing.T) {
}
}
// 存储中的值不是保存时的格式时报错,错误信息中不带原值:其中可能有 Client Secret。
// 存储里的值不是保存时的格式时报错,错误信息里不带原值,因为原值里可能有 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"}`} {
@@ -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
@@ -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 {
@@ -49,8 +49,8 @@ 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 的顺序锁住开启了记录的成员:同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删;
// 加锁的顺序固定,同时写几个成员的事务之间不会死锁。
// 按 ID 的顺序锁住开启了记录的成员。这样同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删。
// 加锁的顺序固定,所以同时写几个成员的事务之间不会死锁。
func (q *Queries) LockListeners(ctx context.Context, ids []string) ([]string, error) {
rows, err := q.db.Query(ctx, lockListeners, ids)
if err != nil {
@@ -147,7 +147,7 @@ type MoveListensParams struct {
FromID string
}
// 并入的规则与 UpsertListens 相同,并入的那份算新写入的;to_id 关闭了记录时只删不并。
// 并入的规则与 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
@@ -185,7 +185,7 @@ type TrimListensParams struct {
Keep int64
}
// 排序与 MemberListens 相同:留下的正是读取时返回的那些。
// 排序与 MemberListens 相同,所以留下的正是读取时返回的那些。
func (q *Queries) TrimListens(ctx context.Context, arg TrimListensParams) error {
_, err := q.db.Exec(ctx, trimListens, arg.MemberIds, arg.Keep)
return err
@@ -10,6 +10,23 @@ 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
`
@@ -20,24 +37,43 @@ 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, 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
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
`
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
}
func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, error) {
rows, err := q.db.Query(ctx, listStoredMedia)
// 曲目 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,
)
if err != nil {
return nil, err
}
@@ -50,7 +86,9 @@ func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, er
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.MediaSize,
&i.CoverURL,
&i.CoverKey,
&i.PreparedAt,
&i.LastPlayedAt,
@@ -66,7 +104,7 @@ func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, er
}
const storedMedia = `-- name: StoredMedia :one
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
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 = $1::text
`
@@ -75,7 +113,9 @@ type StoredMediaRow struct {
Source string
TrackID string
Title string
Artist *string
MediaSize int64
CoverURL *string
CoverKey *string
PreparedAt time.Time
LastPlayedAt *time.Time
@@ -89,7 +129,9 @@ 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,
@@ -83,7 +83,7 @@ type AddMemberCredentialsParams struct {
ID string
}
// 只给只绑定了 GitHub 的账号设置,它的“我喜欢”已经在绑定时创建了。
// 只给只绑定了 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 {
@@ -103,7 +103,7 @@ type BindGitHubParams struct {
ID string
}
// 只给还没有绑定 GitHub 的成员绑定,已经绑定过的成员不受影响,没有返回的行。was_guest 表示成员原来是游客:这次绑定就是注册。
// 只给还没有绑定 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
@@ -194,7 +194,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
@@ -206,8 +206,8 @@ const lockMemberPassword = `-- name: LockMemberPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 FOR NO KEY UPDATE
`
// 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的几次登录依次并入游客的历史,否则两边会按相反的顺序抢同一批历史行而死锁。
// 这把锁让修改密码等到这次登录提交之后才进行,所以修改密码随后删除会话时,会删掉这次登录签发的会话。
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE,这样同一个账号的几次登录会依次并入游客的历史。否则两次登录会按相反的顺序抢同一批历史行而死锁。
func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockMemberPassword, id)
var password_hash string
@@ -280,7 +280,7 @@ type RegisterMemberParams struct {
ID string
}
// 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
// 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码时还要删除其他会话,所以用 AddMemberCredentials。
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 {
@@ -346,7 +346,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,
@@ -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,
@@ -77,7 +77,7 @@ type CopySharedPlaylistItemsRow struct {
Copied int64
}
// 确认分享还在和复制曲目在同一条语句里,看到的是同一个快照:要么完整复制,要么读不到分享。不锁来源歌单:两个人同时复制
// 确认分享还在和复制曲目放在同一条语句里,两者看到的是同一个快照,所以要么完整复制,要么读不到分享。不锁来源歌单,因为两个人同时复制
// 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
func (q *Queries) CopySharedPlaylistItems(ctx context.Context, arg CopySharedPlaylistItemsParams) (CopySharedPlaylistItemsRow, error) {
row := q.db.QueryRow(ctx, copySharedPlaylistItems, arg.ShareID, arg.ToID)
@@ -233,7 +233,7 @@ 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 +248,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
@@ -60,7 +60,7 @@ 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,
+1 -1
View File
@@ -50,7 +50,7 @@ func (s *Store) RecordListen(ctx context.Context, memberIDs []string, l port.Lis
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
@@ -75,7 +75,7 @@ func TestRecordListenKeepsLatestHearingOfEachTrack(t *testing.T) {
t.Errorf("bob 的历史 = %#v,期望 %#v", got, want)
}
// 同一首歌再次记录:heard_at 取较晚的,元数据用新的。
// 同一首歌再次记录时,heard_at 取较晚的,元数据用新的。
renamed := sunnyAt(heard.Add(-time.Hour))
renamed.Meta.Title = "晴天(重制)"
record(t, store, []string{alice.ID}, renamed)
@@ -167,7 +167,7 @@ func TestRecordListenKeepsLatest500(t *testing.T) {
}
}
// 同时开播的几个房间里有同样的两个成员,各自按不同的顺序给出成员,并发写入都成功,每首歌只留一条。
// 同时开播的几个房间里有同样的两个成员,各房间给出成员的顺序不同。并发写入都成功,每首歌只留一条。
func TestConcurrentRecordsOfSameMembers(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
@@ -307,7 +307,7 @@ func TestReplaceSessionKeepsListensWhenRejected(t *testing.T) {
}
}
// 两台设备上的游客同时登录同一个账号,两边听过同一批歌、写入的先后相反:两次合并依次进行,都成功,账号里每首歌只留一条。
// 两台设备上的游客同时登录同一个账号,两边听过同一批歌,写入的先后相反。两次合并依次进行,都成功,账号里每首歌只留一条。
func TestConcurrentReplaceSessionsMergeIntoSameAccount(t *testing.T) {
store, pool := newStore(t)
const songs = 200
+40 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -15,16 +16,45 @@ import (
var _ port.MediaLibrary = (*Store)(nil)
func (s *Store) ListStoredMedia(ctx context.Context) ([]port.StoredMedia, error) {
rows, err := s.q.ListStoredMedia(ctx)
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
})
if err != nil {
return nil, fmt.Errorf("读取已存储的媒体:%w", err)
return port.MediaPage{}, fmt.Errorf("读取已存储的媒体:%w", err)
}
media := make([]port.StoredMedia, len(rows))
for i, r := range rows {
media[i] = storedMedia(query.StoredMediaRow(r))
}
return media, nil
return page, nil
}
func (s *Store) StoredMedia(ctx context.Context, key string) (port.StoredMedia, bool, error) {
@@ -51,7 +81,9 @@ 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),
+122 -12
View File
@@ -1,7 +1,12 @@
package postgres
import (
"cmp"
"fmt"
"math"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -10,10 +15,11 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key, cover string, size int64) {
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key string, meta track.Meta, size int64) {
t.Helper()
meta.Title, meta.PageURL = "标题 "+key, pageURL
p := port.PreparedTrack{
Meta: track.Meta{Title: "标题 " + key, CoverKey: track.ObjectKey(cover), PageURL: pageURL},
Meta: meta,
Result: track.Prepared{Duration: time.Minute, Gain: 1},
MediaKey: key,
MediaSize: size,
@@ -27,9 +33,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", "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)
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)
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: "大厅"})
@@ -37,22 +43,23 @@ 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)})
list, err := store.ListStoredMedia(t.Context())
page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored})
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.Size != 100 || got.CoverKey != "link/aaa/cover.jpg" ||
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" ||
got.StoredAt.IsZero() || got.LastPlayedAt == nil || !got.LastPlayedAt.Equal(t0) {
t.Errorf("播放过的链接 = %+v", got)
}
if b := list[1]; b.Source != track.Link || b.CoverKey != "" || b.LastPlayedAt != nil {
t.Errorf("没有封面、没有播放过的链接 = %+v", b)
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 c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.Artist != "某个 UP 主" || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
t.Errorf("B 站的歌 = %+v", c)
}
@@ -74,7 +81,110 @@ func TestStoredMedia(t *testing.T) {
t.Errorf("清除之后 %v 的准备结果 = %v, %v,期望没有", tr, ok, err)
}
}
if list, err := store.ListStoredMedia(t.Context()); err != nil || len(list) != 1 {
t.Errorf("清除之后的列表 = %+v, %v", list, 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)
}
}
}
+2 -2
View File
@@ -91,7 +91,7 @@ func (s *Store) AddCredentials(ctx context.Context, memberID, username, password
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
// UPDATE 锁住成员行,之后用新的语句删除会话:同时在用 GitHub 登录的设备提交之后才开始删,它签发的会话也会被删掉。
// UPDATE 先锁住成员行,再用新的语句删除会话。这样要等同时在用 GitHub 登录的设备提交之后才开始删,那台设备登录时签发的会话也会被删掉。
n, err := q.AddMemberCredentials(ctx, query.AddMemberCredentialsParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
if err != nil {
return usernameError(err)
@@ -246,7 +246,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
@@ -309,7 +309,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())
@@ -572,7 +572,7 @@ func TestOnlyAccountsHavePasswordOrEmail(t *testing.T) {
}
}
// 补设用户名和密码时,用 GitHub 登录的另一台设备恰好在签发会话:补设要等它提交,再把它签发的会话一并删除。
// 补设用户名和密码时,用 GitHub 登录的另一台设备恰好在签发会话。补设要等那台设备的事务提交,再把它签发的会话一并删除。
func TestAddCredentialsWaitsForGitHubLogin(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
+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 分组,它才不会与日志的固定字段 version 重名。
// rivermigrate 的日志带有 version 字段,而 slog 会把重名的字段写两次。放进 river 分组,rivermigrate 的 version 才不会和日志的固定字段 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)
}
+4 -4
View File
@@ -96,7 +96,7 @@ func (s *Store) ImportPlaylist(ctx context.Context, ownerID, name string, items
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
@@ -148,7 +148,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 {
@@ -343,7 +343,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)
@@ -376,7 +376,7 @@ func (s *Store) SetPlaylistShare(ctx context.Context, ownerID, id, shareID strin
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)
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"github.com/dihemoe/alisten/db/migrations"
)
// 只读取版本表:goose 的 HasPending 在缺少版本表时会建表,就绪检查不能修改数据库。
// 只读取版本表,不用 goose 的 HasPending,因为 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)
}
+52 -1
View File
@@ -2,11 +2,34 @@ 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 {
@@ -16,7 +39,35 @@ func (c *Client) CurrentAccount(ctx context.Context) (*port.SourceAccount, error
if id == "" {
return nil, nil
}
return &port.SourceAccount{ID: id}, 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
}
func qqAccountID(cookie string) string {
+78 -6
View File
@@ -4,15 +4,39 @@ import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
"github.com/dihemoe/alisten/internal/app/port"
)
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 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 TestCurrentAccount(t *testing.T) {
@@ -37,7 +61,7 @@ func TestCurrentAccount(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := accountTestClient(tc.cookie, nil).CurrentAccount(t.Context())
got, err := accountTestClient(t, tc.cookie, fixture(t, "account.json")).CurrentAccount(t.Context())
if err != nil {
t.Fatal(err)
}
@@ -47,7 +71,7 @@ func TestCurrentAccount(t *testing.T) {
}
return
}
want := &port.SourceAccount{ID: tc.wantID}
want := &port.SourceAccount{ID: tc.wantID, VIP: new(bool), Verified: true}
if !reflect.DeepEqual(got, want) {
t.Fatalf("账号资料 = %+v,期望 %+v", got, want)
}
@@ -55,9 +79,57 @@ 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("读取失败")
if _, err := accountTestClient("", want).CurrentAccount(t.Context()); !errors.Is(err, want) {
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) {
t.Fatalf("读取凭据的错误 = %v,期望 %v", err, want)
}
}
func boolPtr(b bool) *bool { return &b }
+60
View File
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,69 @@
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
@@ -0,0 +1 @@
{"code":0,"req":{"code":301,"data":{}}}
@@ -0,0 +1 @@
{"code":0,"req":{"code":0,"data":{}}}
+1
View File
@@ -0,0 +1 @@
{"code":0,"req":{"code":0,"data":{"identity":{"vip":0},"svip":1}}}
+1
View File
@@ -0,0 +1 @@
{"code":0,"req":{"code":0,"data":{"identity":{"vip":1},"svip":0}}}
+1
View File
@@ -0,0 +1 @@
{"code":0,"req":{"code":0,"data":{"identity":{"vip":0},"svip":0}}}
@@ -0,0 +1 @@
{"code":0,"subcode":0,"cdnum":0,"cdlist":[]}
@@ -0,0 +1 @@
{"code":10,"subcode":0,"cdnum":0,"cdlist":[]}
+1
View File
@@ -0,0 +1 @@
{"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 计算写入的期限,这个上限只是兜底,要长于持锁重新连接的时间:
// 订阅连接上的写入不使用调用方的期限。go-redis 会把写超时当作连接已坏,重建共用的订阅连接,本实例的全部订阅随之中断。
// 而调用方可能在 go-redis 内部的锁上一直等到自己的期限用完才开始写,比如 go-redis 正持锁重新连接的时候。
// go-redis 从真正开始写的时刻按 WriteTimeout 计算写入的期限,sharedWriteTimeout 只是兜底,要长于持锁重新连接的时间。
// 默认的连接、读、写超时各 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,收到它的回复时订阅已经生效。
// Redis 按顺序处理一个连接上的命令,所以订阅之后在同一个连接上发一次 PING,收到 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 无法察觉,
// 由这里换一个新连接。两种情况都在恢复订阅之前先报告 SubscriptionLost,让调用方尽早释放房间、断开会话。
// 连接出错时,go-redis 会自行重新连接并重新订阅全部频道,但断开期间的消息已经丢失。连接只是停止响应时 go-redis 察觉不到,
// 由 Receive 换一个新连接。两种情况都在恢复订阅之前先报告 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()
}
// 这里不做 I/O:先报告 SubscriptionLost,下一次接收时再连接并订阅原来的频道,停止响应的服务端不会拖延报告。
// replace 不做 I/O。Receive 先报告 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:记录一个 consumer span,owner 处理这条输入时的调用都在它之下。
// 带有追踪上下文的输入延续网关的 trace。received 记录一个 consumer span,owner 处理这条输入时的调用都在这个 span 之下。
// 这个 span 只标记收到输入的时刻,处理的耗时由它之下的调用体现。
func (b *Bus) received(carrier map[string]string, roomID string) context.Context {
ctx := propagation.TraceContext{}.Extract(context.Background(), propagation.MapCarrier(carrier))
+7 -6
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,7 +646,8 @@ func (p *freezeProxy) Freeze() {
}
}
// 此后新建的连接在建立后停住,客户端收不到任何回复,直到调用返回的 release;每停住一个连接,held 通知一次。
// 此后新建的连接都收不到任何回复,直到调用返回的 release。接收循环停在第一个新连接上,held 只在这时通知一次。
// 后面的连接留在 accept 队列里,不会再通知。
func (p *freezeProxy) HoldNew() (held <-chan struct{}, release func()) {
p.mu.Lock()
defer p.mu.Unlock()
@@ -839,7 +840,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] {
+1 -1
View File
@@ -177,7 +177,7 @@ func (c *Codes) save(ctx context.Context, key string, record any, ttl time.Durat
return c.client.Set(ctx, key, value, ttl).Err()
}
// GETDEL 让取出和删除是一个原子操作:并发的两次兑换只有一次能拿到。
// GETDEL 让取出和删除成为一个原子操作,所以并发的两次兑换只有一次能拿到。
func (c *Codes) take(ctx context.Context, key string, record any) error {
value, err := c.client.GetDel(ctx, key).Bytes()
if errors.Is(err, goredis.Nil) {
+1 -1
View File
@@ -16,7 +16,7 @@ var _ port.LoginDevices = (*Devices)(nil)
const devicePrefix = "device:"
// 登录设备凭证,键中只有它的哈希,值是所属成员的 ID 和签发时的密码指纹。
// 存放登录设备凭证。键里只有凭证的哈希,值是所属成员的 ID 和签发时的密码指纹。
type Devices struct {
client *goredis.Client
}
+1 -1
View File
@@ -109,7 +109,7 @@ func TestLeaseExpires(t *testing.T) {
}
}
// 本实例重新取得自己的租约就是房间的一次新运行:它从空的在线列表开始,上一次运行写下的在线人数作废。
// 本实例重新取得自己的租约,就是房间的一次新运行。新运行从空的在线列表开始,上一次运行写下的在线人数作废。
func TestReacquiringOwnLeaseResetsOnlineCount(t *testing.T) {
a := NewLeases(newClient(t), "a")
if ok, err := a.Acquire(t.Context(), hall, time.Minute); err != nil || !ok {
+1 -1
View File
@@ -1,4 +1,4 @@
// Package redis 只存放可以丢失的协调数据:Redis 被清空后系统能自行恢复。
// Package redis 只存放可以丢失的协调数据,Redis 被清空后系统能自行恢复。
package redis
import (
+2 -2
View File
@@ -24,8 +24,8 @@ type Resolver struct {
var _ port.ShortLinks = (*Resolver)(nil)
// allowed 检查每一跳(包括起点)的主机。跳转到 done 认得的地址时就停下,不再请求它:
// 网易云的短链接最后一跳是很慢的歌单页面,逐跳请求到底会超过总超时。
// allowed 检查每一跳(包括起点)的主机。跳转到 done 认得的地址时就停下,不再请求这个地址,
// 因为网易云的短链接最后一跳是很慢的歌单页面,逐跳请求到底会超过总超时。
func New(allowed func(host string) bool, done func(link string) bool) *Resolver {
return &Resolver{
allowed: allowed,
+2 -2
View File
@@ -47,14 +47,14 @@ func New(cfg Config) *Storage {
// 为兼容不支持虚拟主机寻址的 S3 服务,统一使用路径寻址。
UsePathStyle: true,
Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
// 仅在操作要求时计算或验证校验和:部分 S3 兼容服务不支持 SDK 默认添加的灵活校验和。
// 只在操作要求时计算或验证校验和,因为部分 S3 兼容服务不支持 SDK 默认添加的灵活校验和。
RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired,
})
return &Storage{client: client, bucket: cfg.Bucket, region: cfg.Region}
}
// 只为新建的 bucket 设置访问策略:匿名请求只能读取 link/ 下的对象,不能列出对象。
// 只为新建的 bucket 设置访问策略。策略让匿名请求只能读取 link/ 下的对象,不能列出对象。
// 设置访问策略失败时删除刚创建的 bucket,否则下一次检查会把这个没有策略的 bucket 当作已经配置好的。
func (s *Storage) EnsureBucket(ctx context.Context) (created bool, err error) {
_, err = s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
+5 -5
View File
@@ -14,21 +14,21 @@ const upstreamTimeout = 30 * time.Second
// 除回环、私有(包括 IPv6 唯一本地地址)、链路本地(包括云元数据服务的 169.254.169.254)、组播和未指定地址之外,
// 代理还拒绝连接这些地址段。
var blocked = []netip.Prefix{
// 「本网络」地址段,不会出现在公网上;IsGlobalUnicast 只排除其中的 0.0.0.0。
// 「本网络」地址段,不会出现在公网上。IsGlobalUnicast 只排除其中的 0.0.0.0。
netip.MustParsePrefix("0.0.0.0/8"),
// 运营商级 NAT 使用的共享地址段。
netip.MustParsePrefix("100.64.0.0/10"),
// IETF 协议分配、基准测试和保留地址段:不会出现在公网上,部分内网和云的网络把它们用作内部地址。
// IETF 协议分配、基准测试和保留地址段。这些地址不会出现在公网上,部分内网和云的网络把它们用作内部地址。
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("240.0.0.0/4"),
// 文档专用地址段:只用于示例,不会出现在公网上,部分内网把它们用作内部地址。
// 文档专用地址段,只用于示例,不会出现在公网上,部分内网把它们用作内部地址。
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("3fff::/20"),
// 内嵌 IPv4 地址的 IPv6 地址段:NAT64、SIIT 网关和隧道会把它们转换成任意 IPv4 地址,包括私有地址。
// 内嵌 IPv4 地址的 IPv6 地址段。NAT64、SIIT 网关和隧道会把这些地址转换成任意 IPv4 地址,包括私有地址。
netip.MustParsePrefix("64:ff9b::/96"),
netip.MustParsePrefix("64:ff9b:1::/48"),
netip.MustParsePrefix("::/96"),
@@ -58,7 +58,7 @@ type refusedError struct {
func (e *refusedError) Error() string { return fmt.Sprintf("%s 不是公网地址", e.host) }
// dialPublic 自己解析目标的域名,只连接解析出的公网地址:连接时不再解析域名,DNS 重绑定就无法绕过检查。
// dialPublic 自己解析目标的域名,只连接解析出的公网地址。连接时不再解析域名,这样 DNS 重绑定就无法绕过检查。
func dialPublic(resolver *net.Resolver) func(ctx context.Context, network, address string) (net.Conn, error) {
return func(ctx context.Context, _, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
+2 -2
View File
@@ -106,9 +106,9 @@ func (s *dnsServer) resolver() *net.Resolver {
}}
}
// 回应只有一个问题的查询:问题原样放回,答案是 A 或 AAAA 记录,没有答案时是空的成功回应。
// 回应只有一个问题的查询。问题原样放回,答案是 A 或 AAAA 记录,没有答案时回一个空的成功回应。
func (s *dnsServer) reply(query []byte) []byte {
// 问题从第 12 个字节开始:域名是「长度加标签」的序列,以 0 结尾,之后是 2 字节的类型和 2 字节的类。
// 问题从第 12 个字节开始。域名是「长度加标签」的序列,以 0 结尾,之后是 2 字节的类型和 2 字节的类。
end := 12
for end < len(query) && query[end] != 0 {
end += int(query[end]) + 1
+4 -4
View File
@@ -101,7 +101,7 @@ func verbose(t *testing.T, link string, extra ...string) (stderr []byte, err err
return stderr, err
}
// 不包括 ffprobe:yt-dlp 不给它传后处理器的参数。
// 不包括 ffprobe,因为 yt-dlp 不给 ffprobe 传后处理器的参数。
func ffmpegCommands(stderr []byte) [][]string {
const marker = "[debug] ffmpeg command line: "
var commands [][]string
@@ -119,7 +119,7 @@ func ffmpegCommands(stderr []byte) [][]string {
}
}
// 读到第一个不在引号内的换行为止,按空白拆成参数;单引号内的内容(可以跨行)属于同一个参数。返回参数和消耗的字节数。
// 读到第一个不在引号内的换行为止,按空格拆成参数。单引号内的内容(可以跨行)属于同一个参数。返回参数和消耗的字节数。
func shellWords(s string) ([]string, int) {
var words []string
var word strings.Builder
@@ -242,7 +242,7 @@ func TestFFmpegDownloaderReadsLocalFilesOnly(t *testing.T) {
}
}
// 在 WAV 的 fmt 块和 data 块之间插入 LIST/INFO 块,INAM 是标题:ffmpeg 转码时会把它复制成 mp3 的 ID3 标签。
// 在 WAV 的 fmt 块和 data 块之间插入 LIST/INFO 块,INAM 是标题。ffmpeg 转码时会把这个标题复制成 mp3 的 ID3 标签。
func taggedWAV(title string) []byte {
wav := porttest.SilentWAV(time.Second)
const dataChunk = 36
@@ -309,7 +309,7 @@ func TestConfigFilesAreIgnored(t *testing.T) {
_, _, link := startSite(t)
p := siteProxy(t)
dir := t.TempDir()
// media.test 只有测试的代理能解析:下载成功说明请求经过了命令行指定的代理。
// media.test 只有测试的代理能解析,所以下载成功说明请求经过了命令行指定的代理。
if _, stderr, err := run(t.Context(), dir, args(link("/page"), dir, p.url, "")); err != nil {
t.Fatalf("yt-dlp 失败:%v\n%s", err, stderr)
}
+1 -1
View File
@@ -21,7 +21,7 @@ type proxy struct {
mu sync.Mutex
closed bool
refused bool
// 隧道两端的连接在 Hijack 之后不再由 server 管理,代理关闭时要由这里关闭。
// 隧道两端的连接在 Hijack 之后不再由 server 管理,代理关闭时要由 Close 逐个关闭。
tunnels map[*tunnel]struct{}
running sync.WaitGroup
}
+14 -14
View File
@@ -1,5 +1,5 @@
// worker 会请求用户提交的任意地址,因此 yt-dlp 的网络请求必须经过只连接公网地址的进程内代理;
// ffmpeg 无论作为下载器还是后处理器都只能读取本地文件。
// worker 会请求用户提交的任意地址,因此 yt-dlp 的网络请求必须经过只连接公网地址的进程内代理。
// ffmpeg 无论作为下载器还是后处理器,都只能读取本地文件。
package ytdlp
import (
@@ -27,7 +27,7 @@ import (
)
const (
// yt-dlp 从 PATH 中查找,它再从 PATH 中查找 ffmpeg、ffprobe 和 Deno。
// yt-dlp 从 PATH 里查找,yt-dlp 再从 PATH 里查找 ffmpeg、ffprobe 和 Deno。
executable = "yt-dlp"
waitDelay = 5 * time.Second
outputLimit = 64 << 10
@@ -54,8 +54,8 @@ type Downloader struct {
var _ port.LinkDownloader = (*Downloader)(nil)
// observe 记下每次运行 yt-dlp 的耗时和失败的原因,成功时 failure 为空;任务被取消(worker 退出)的运行不记。
// cookies 必须非 nil:没有 cookies 来源的调用方传恒返回 ok=false 的实现。
// observe 记下每次运行 yt-dlp 的耗时和失败的原因,成功时 failure 为空。任务被取消(worker 退出)的运行不记。
// cookies 不能为 nil。没有 cookies 来源的调用方,传一个总是返回 ok=false 的实现。
func New(root string, resolver *net.Resolver, cookies port.LinkCookies, observe func(elapsed time.Duration, failure string), logger *slog.Logger) *Downloader {
return &Downloader{root: root, resolver: resolver, cookies: cookies, observe: observe, logger: logger}
}
@@ -66,7 +66,7 @@ func (d *Downloader) Download(ctx context.Context, link string) (port.LinkDownlo
return port.LinkDownload{}, 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 {
@@ -121,9 +121,9 @@ func args(link, dir, proxy, cookies string) []string {
"--ignore-config",
"--proxy", proxy,
"--downloader", "native",
// ffmpeg 的请求不经过代理,所以只允许它读本地文件。原生下载器处理不了时(比如加密的 HLS、直播),yt-dlp 会改用 ffmpeg 下载;
// ffmpeg 的请求不经过代理,所以只允许它读本地文件。原生下载器处理不了时(比如加密的 HLS、直播),yt-dlp 会改用 ffmpeg 下载。
// 后处理器也用 ffmpeg 处理下载到的文件,而文件可能引用网络上的地址。给后处理器的参数要写成「后处理器名+ffmpeg_i」,
// 不带后处理器名的 ffmpeg_i 只对 ffmpeg 下载器生效。这里列出的后处理器会处理音频文件:转 mp3,以及 HLS 和 DASH 的封装修复。
// 不带后处理器名的 ffmpeg_i 只对 ffmpeg 下载器生效。下面列出的后处理器会处理音频文件:转 mp3,以及 HLS 和 DASH 的封装修复。
"--downloader-args", "ffmpeg_i:-protocol_whitelist file",
"--postprocessor-args", "ExtractAudio+ffmpeg_i:-protocol_whitelist file",
"--postprocessor-args", "FixupM3u8+ffmpeg_i:-protocol_whitelist file",
@@ -132,10 +132,10 @@ func args(link, dir, proxy, cookies string) []string {
// 来源已经是 mp3 时 yt-dlp 不转码,文件连同标签原样保留,存储的音频仍然不可信。
// 转出的 mp3 写到上限多一个字节就停止,截断的文件由准备流程拒绝。
"--postprocessor-args", "ExtractAudio+ffmpeg_o:-map_metadata -1 -fs " + strconv.Itoa(port.MaxAudioSize+1),
// --no-playlist 只对同时指向视频和播放列表的链接有效;对播放列表本身,--playlist-items 1 只下载第一项。
// --no-playlist 只对同时指向视频和播放列表的链接有效。对于播放列表本身,--playlist-items 1 只下载第一项。
"--no-playlist", "--playlist-items", "1",
"--format", "bestaudio/best",
// 只在服务端给出 Content-Length 时生效;准备流程上传之前还会检查大小。
// --max-filesize 只在服务端给出 Content-Length 时生效。准备流程上传之前还会检查大小。
"--max-filesize", strconv.Itoa(port.MaxAudioSize),
"--extract-audio", "--audio-format", "mp3",
"--write-thumbnail",
@@ -143,8 +143,8 @@ func args(link, dir, proxy, cookies string) []string {
"--output", "audio.%(ext)s",
"--output", "thumbnail:cover.%(ext)s",
"--no-progress",
// 屏幕输出里有页面可控的标题,元数据混在其中无法可靠地取出,所以写进单独的文件。不用 --print 也是因为它带上 --quiet,
// --max-filesize 中止下载时的提示就不会输出。
// 屏幕输出里有页面可控的标题,元数据混在里面无法可靠地取出,所以写进单独的文件。不用 --print 也是因为 --print 会带上 --quiet,
// 这样 --max-filesize 中止下载时的提示就不会输出。
"--print-to-file", "after_move:%(.{title,artist,uploader,album,duration})j", metaFile,
// 链接以 - 开头时会被当作选项。
"--", link,
@@ -169,7 +169,7 @@ func run(ctx context.Context, dir string, args []string) (stdout, stderr []byte,
return out.buf, redactCookies(errOut.lines()), err
}
// yt-dlp 读 cookies 文件出错时,会在这些说明后面打印出错的行或值,日志和任务错误里都不能出现。
// yt-dlp 读 cookies 文件出错时,会在这些说明后面打印出错的行或值,而这些内容不能出现在日志和任务错误里。
var cookieEcho = regexp.MustCompile(`(cookie file entry|Netscape format cookies file|could not convert string to float)[^\S\n]*\S[^\n]*`)
func redactCookies(stderr []byte) []byte {
@@ -203,7 +203,7 @@ func (t *tail) Write(p []byte) (int, error) {
return len(p), nil
}
// 截断后开头的半行认不出是不是 cookies 的回显,丢掉。
// 截断后开头的半行认不出是不是 cookies 的回显,所以丢掉。
func (t *tail) lines() []byte {
if !t.cut {
return t.buf
+4 -4
View File
@@ -24,7 +24,7 @@ import (
"github.com/dihemoe/alisten/internal/app/port/porttest"
)
// 网页中的地址都用 media.test,只有测试的代理能把它解析到本机:请求如果绕过代理,就无法连接。
// 网页里的地址都用 media.test,只有测试的代理能把它解析到本机,所以请求如果绕过代理,就无法连接。
func startSite(t *testing.T) (server *httptest.Server, requests *atomic.Int32, link func(path string) string) {
t.Helper()
requests = &atomic.Int32{}
@@ -138,7 +138,7 @@ func (zeros) ReadAt(p []byte, _ int64) (int, error) {
return len(p), nil
}
// 音频的内容全是零:yt-dlp 如果没有在看到长度时中止,就会下载完再转码失败。
// 音频的内容全是零。yt-dlp 如果没有在看到长度时中止,就会下载完再转码失败。
func TestYtdlpRejectsAudioOverLimit(t *testing.T) {
link, _ := startPaths(t, map[string]http.Handler{
"/page": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -318,7 +318,7 @@ func TestArgsEndWithLink(t *testing.T) {
}
}
// 转出超限的 mp3 要几百 MB 的输入,测不起;ffmpeg 遇到 -fs 时怎样截断由 ffmpeg 适配器的测试确认。
// 转出超限的 mp3 要几百 MB 的输入,测不起。ffmpeg 遇到 -fs 时怎样截断,由 ffmpeg 适配器的测试确认。
func TestArgsCapExtractedAudio(t *testing.T) {
want := "ExtractAudio+ffmpeg_o:-map_metadata -1 -fs " + strconv.Itoa(port.MaxAudioSize+1)
if got := args("http://example.com/a", "/tmp/d", "http://127.0.0.1:1", ""); !slices.Contains(got, want) {
@@ -534,7 +534,7 @@ func TestDownloadFailsWhenCookiesUnavailable(t *testing.T) {
}
func TestDownloadRedactsCookiesEchoedByYtdlp(t *testing.T) {
// 第 2 到 4 行格式不对,yt-dlp 跳过并打印出来,第 3 行长到截断之后的输出从它的半行开始;
// 第 2 到 4 行格式不对,yt-dlp 跳过并打印出来。第 3 行很长,截断之后的输出从它的半行开始。
// 最后一行有两层 #HttpOnly_ 前缀,读取失败,报错和 traceback 里都带着它的内容。
path := filepath.Join(t.TempDir(), "cookies.txt")
content := "# Netscape HTTP Cookie File\n" +
+5 -5
View File
@@ -43,7 +43,7 @@ func (s *Service) Authorize(token string) error {
if s.token == "" {
return ErrDisabled
}
// 比较哈希而不是原文:长度不同时 ConstantTimeCompare 立即返回,会泄露管理 token 的长度。
// 比较哈希而不是原文,因为长度不同时 ConstantTimeCompare 会立即返回,泄露管理 token 的长度。
want, got := sha256.Sum256([]byte(s.token)), sha256.Sum256([]byte(token))
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
return ErrInvalidToken
@@ -62,7 +62,7 @@ func (s *Service) ListRooms(ctx context.Context) ([]lobby.Room, error) {
type NewRoom struct {
Name string
Description string
// 空字符串表示没有别名。别名必须已经按格式校验:存储按格式区分 UUID 和别名,本身不做检查。
// 空字符串表示没有别名。别名必须已经按格式校验过,因为存储按格式区分 UUID 和别名,自己不做检查。
Slug string
Password string
}
@@ -85,13 +85,13 @@ func (s *Service) CreateRoom(ctx context.Context, r NewRoom) (string, error) {
type RoomChange struct {
Name *string
Description *string
// 别名必须已经按格式校验:存储按格式区分 UUID 和别名,本身不做检查。
// 别名必须已经按格式校验过,因为存储按格式区分 UUID 和别名,自己不做检查。
Slug *string
Password *string
}
// id 必须是 UUID。名称和描述由房间修改,房间落盘之后才返回。设置或修改密码会作废房间成员的票据并断开会话和成员的事件流,
// 这一步无法撤销,所以放在最后:前面任何一步失败时票据仍然有效,重试也不会再次作废重新签发的票据。
// id 必须是 UUID。名称和描述由房间修改,房间落盘之后才返回。设置或修改密码会作废房间成员的票据,并断开会话和成员的事件流。
// 这一步无法撤销,所以放在最后。这样前面任何一步失败时票据都还有效,重试时也不会把重新签发的票据再作废一次。
// 别名在名称和描述之前修改,别名已被占用时不做任何修改。取消密码不影响已有的票据。
func (s *Service) UpdateRoom(ctx context.Context, id string, c RoomChange) error {
r, err := s.persistentRoom(ctx, id)
+3 -3
View File
@@ -58,7 +58,7 @@ func newService(t *testing.T, token string) fixture {
func TestSetYouTubeCookies(t *testing.T) {
f := newService(t, "secret")
// Netscape 格式原样保存,换行统一成 \n;#HttpOnly_ 开头的是有效 cookie 行,不是注释。
// Netscape 格式原样保存,换行统一成 \n。#HttpOnly_ 开头的是有效 cookie 行,不是注释。
input := "# Netscape HTTP Cookie File\r\n#HttpOnly_.youtube.com\tTRUE\t/\tTRUE\t1798765432\tSID\tfictional\r\n.youtube.com\tTRUE\t/\tFALSE\t1798765432\tHSID\tother\r\n"
want := "# Netscape HTTP Cookie File\n#HttpOnly_.youtube.com\tTRUE\t/\tTRUE\t1798765432\tSID\tfictional\n.youtube.com\tTRUE\t/\tFALSE\t1798765432\tHSID\tother"
if err := f.admin.SetYouTubeCookies(t.Context(), input); err != nil {
@@ -68,7 +68,7 @@ func TestSetYouTubeCookies(t *testing.T) {
t.Errorf("保存的 cookies = %q,期望 %q", got, want)
}
// 请求头格式转成 Netscape:带和不带 Cookie: 前缀;cookie 名字保留原始大小写。
// 请求头格式转成 Netscape,带不带 Cookie: 前缀都可以。cookie 名字保留原始大小写。
for _, tc := range []struct{ header, want string }{
{"SID=fictional; HSID=other", "\tSID\tfictional"},
{"Cookie: SID=fictional; HSID=other", "\tSID\tfictional"},
@@ -467,7 +467,7 @@ func TestBilibiliQRLogin(t *testing.T) {
}
}
// 票据已经作废,断开的通知发不出去时照常成功:用旧票据建立的连接由网关定期核对凭证时断开。
// 票据已经作废之后,断开的通知发不出去也照常成功,因为用旧票据建立的连接会在网关定期核对凭证时断开。
func TestUpdateRoomSucceedsWhenRevokeFails(t *testing.T) {
f := newService(t, "secret")
id, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "大厅"})
+3 -3
View File
@@ -26,8 +26,8 @@ func normalizeCookies(input string) (string, error) {
return "", ErrUnrecognizedCookies
}
// 空行和注释行原样保留但不校验;#HttpOnly_ 开头的是有效 cookie 行,不是注释。
// 其余每行必须是制表符分隔的 7 列,且至少有一行。
// 空行和注释行原样保留,但不校验。#HttpOnly_ 开头的是有效 cookie 行,不是注释。
// 其余每行必须是制表符分隔的 7 列,而且这样的行至少要有一行。
func normalizeNetscape(s string) (string, bool) {
lines := strings.Split(s, "\n")
valid := 0
@@ -49,7 +49,7 @@ func normalizeNetscape(s string) (string, bool) {
return strings.Join(lines, "\n"), true
}
// Cookie 请求头只有一行:可以带不区分大小写的 Cookie: 前缀,内容是用 ; 分隔的 名字=值。
// Cookie 请求头只有一行。开头可以带 Cookie: 前缀,前缀不区分大小写。内容是用 ; 分隔的 名字=值。
// 统一转换成 .youtube.com 的 Secure cookie,一年后过期。
func convertCookieHeader(s string) (string, bool) {
if strings.ContainsAny(s, "\n\t") {
+24 -13
View File
@@ -15,7 +15,7 @@ import (
const (
searchBurst = 10
searchRefill = time.Minute
// 最长等待与上游接口 30 秒的超时之和不超过客户端 60 秒的请求超时。
// 最长等待加上上游接口 30 秒的超时,不超过客户端 60 秒的请求超时。
maxSearchWait = 30 * time.Second
storeTimeout = 10 * time.Second
autoDJCandidates = 25
@@ -24,7 +24,7 @@ const (
var ErrRateLimited = errors.New("搜索过于频繁,请稍后再试。")
// RejectedLinkError 表示链接属于某个音源,但不能点播,例如 B 站的番剧和直播;它的文本就是返回给点歌人的原因。
// RejectedLinkError 表示链接属于某个音源,但不能点播,例如 B 站的番剧和直播。RejectedLinkError 的文本就是返回给点歌人的原因。
type RejectedLinkError struct {
Reason string
}
@@ -33,7 +33,7 @@ func (e *RejectedLinkError) Error() string { return e.Reason }
type Service struct {
netease port.NetEaseSource
qq port.Source
qq port.PlaylistSource
bilibili port.BilibiliSource
links port.BilibiliLinks
tracks port.Tracks
@@ -42,11 +42,11 @@ type Service struct {
quota port.RoomQuota
}
func New(netease port.NetEaseSource, qq port.Source, bilibili port.BilibiliSource, links port.BilibiliLinks, tracks port.Tracks, history port.PlayHistory, rooms port.RoomDirectory, quota port.RoomQuota) *Service {
func New(netease port.NetEaseSource, qq port.PlaylistSource, bilibili port.BilibiliSource, links port.BilibiliLinks, tracks port.Tracks, history port.PlayHistory, rooms port.RoomDirectory, quota port.RoomQuota) *Service {
return &Service{netease: netease, qq: qq, bilibili: bilibili, links: links, tracks: tracks, history: history, rooms: rooms, quota: quota}
}
// SearchTracks 只在上游一次取回的结果中分页,page 从 1 开始;total 是取回的结果数。
// SearchTracks 只在上游一次取回的结果中分页,page 从 1 开始。total 是取回的结果数。
func (s *Service) SearchTracks(ctx context.Context, roomID string, source track.Source, keyword string, page, size int64) (songs []port.Song, total int64, err error) {
src, err := s.searchable(source)
if err != nil {
@@ -78,8 +78,8 @@ func (s *Service) SearchPlaylists(ctx context.Context, roomID string, source tra
}
func (s *Service) PlaylistTracks(ctx context.Context, roomID string, source track.Source, playlistID string) ([]port.Song, error) {
if source != track.NetEase {
return nil, unsupported(source, "歌单内的歌曲")
if _, err := s.playlistSource(source); err != nil {
return nil, err
}
if err := s.take(ctx, roomID); err != nil {
return nil, err
@@ -87,12 +87,13 @@ func (s *Service) PlaylistTracks(ctx context.Context, roomID string, source trac
return s.PlaylistSongs(ctx, source, playlistID)
}
// 不取房间的搜索额度:导入歌单按成员另外限流。
// 不取房间的搜索额度,因为导入歌单按成员另外限流。
func (s *Service) PlaylistSongs(ctx context.Context, source track.Source, playlistID string) ([]port.Song, error) {
if source != track.NetEase {
return nil, unsupported(source, "歌单内的歌曲")
src, err := s.playlistSource(source)
if err != nil {
return nil, err
}
songs, err := s.netease.PlaylistTracks(ctx, playlistID)
songs, err := src.PlaylistTracks(ctx, playlistID)
if err != nil {
return nil, upstream(source, "获取歌单内的歌曲", err)
}
@@ -135,7 +136,7 @@ func (s *Service) TrackMeta(ctx context.Context, t track.Track) (track.Meta, err
return m, nil
}
// B 站的链接转成 B 站音源的曲目,其他链接仍是链接曲目:链接准备完成后才有元数据,在此之前以链接本身作为标题。
// B 站的链接转成 B 站音源的曲目,其他链接仍是链接曲目。链接曲目准备完成后才有元数据,在此之前以链接本身作为标题。
func (s *Service) ResolveLink(ctx context.Context, link string) (track.Track, track.Meta, error) {
id, ok, err := s.links.Recognize(ctx, link)
var rejected *port.RejectedError
@@ -282,6 +283,16 @@ func (s *Service) metaSource(source track.Source) (func(context.Context, string)
return nil, unsupported(source, "点歌前查询元数据")
}
func (s *Service) playlistSource(source track.Source) (port.PlaylistSource, error) {
switch source {
case track.NetEase:
return s.netease, nil
case track.QQ:
return s.qq, nil
}
return nil, unsupported(source, "歌单内的歌曲")
}
func (s *Service) searchable(source track.Source) (port.Source, error) {
switch source {
case track.NetEase:
@@ -300,7 +311,7 @@ func upstream(source track.Source, what string, err error) error {
return fmt.Errorf("在%s%s:%w:%w", source, what, err, port.ErrProviderFailed)
}
// page 从 1 开始,size 至少为 1;两者都来自客户端,乘积可能溢出。
// page 从 1 开始,size 至少为 1。page 和 size 都来自客户端,乘积可能溢出。
func paginate[T any](items []T, page, size int64) []T {
n := int64(len(items))
if page-1 > n/size {
+22 -3
View File
@@ -255,8 +255,14 @@ func TestPlaylistSongsSkipsRoomQuota(t *testing.T) {
if f.quota.calls != 0 {
t.Errorf("取了 %d 次房间的搜索额度,导入不走房间的额度", f.quota.calls)
}
if _, err := f.svc.PlaylistSongs(t.Context(), track.QQ, "p1"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("QQ 音乐的歌单 = %v,期望 ErrProviderFailed", err)
qqSongs := []port.Song{song(track.QQ, "0039MnYb0qxYhV", "晴天"), song(track.QQ, "004Z8Ihr0JIu5s", "七里香")}
f.add(f.qq, qqSongs...)
f.qq.AddPlaylist(port.Playlist{ID: "q1", Name: "周杰伦"}, "004Z8Ihr0JIu5s", "0039MnYb0qxYhV")
if got, err := f.svc.PlaylistSongs(t.Context(), track.QQ, "q1"); err != nil || !reflect.DeepEqual(got, []port.Song{qqSongs[1], qqSongs[0]}) {
t.Errorf("QQ 音乐歌单内的歌曲 = %v, %v,期望按歌单的顺序", got, err)
}
if _, err := f.svc.PlaylistSongs(t.Context(), track.Bilibili, "p1"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("B 站的歌单 = %v,期望 ErrProviderFailed", err)
}
f.netease.Fail(errors.New("网易云不可用"))
if _, err := f.svc.PlaylistSongs(t.Context(), track.NetEase, "p1"); !errors.Is(err, port.ErrProviderFailed) {
@@ -264,6 +270,19 @@ func TestPlaylistSongsSkipsRoomQuota(t *testing.T) {
}
}
func TestPlaylistTracksTakesRoomQuota(t *testing.T) {
f := newFixture(t, history{})
songs := []port.Song{song(track.QQ, "0039MnYb0qxYhV", "晴天")}
f.add(f.qq, songs...)
f.qq.AddPlaylist(port.Playlist{ID: "q1", Name: "周杰伦"}, "0039MnYb0qxYhV")
if got, err := f.svc.PlaylistTracks(t.Context(), f.ephemeral, track.QQ, "q1"); err != nil || !reflect.DeepEqual(got, songs) || f.quota.calls != 1 {
t.Errorf("在临时房间里打开 QQ 音乐的歌单 = %v, %v,取了 %d 次额度,期望取到歌曲、取 1 次额度", got, err, f.quota.calls)
}
if _, err := f.svc.PlaylistTracks(t.Context(), f.ephemeral, track.Bilibili, "q1"); !errors.Is(err, port.ErrProviderFailed) || f.quota.calls != 1 {
t.Errorf("B 站的歌单 = %v,取了 %d 次额度,期望 ErrProviderFailed,而且不取额度", err, f.quota.calls)
}
}
func TestTrackMetaOfBilibili(t *testing.T) {
f := newFixture(t, history{})
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
@@ -312,7 +331,7 @@ func TestResolveLink(t *testing.T) {
func TestResolveLinkFailures(t *testing.T) {
f := newFixture(t, history{})
const link = "https://www.bilibili.com/video/BV1xx411c7mD"
// 音源里也登记这个 ID:不检查格式时,它会被当作 B 站的曲目点进队列。
// 音源里也登记这个 ID,这样不检查格式的话,这个 ID 就会被当作 B 站的曲目点进队列。
f.bilibili.AddVideo("BV1xx411c7mD/1", track.Meta{Title: "第 1 P"}, nil, nil, "")
f.bilibili.AddLink(link, "BV1xx411c7mD/1")
if _, _, err := f.svc.ResolveLink(t.Context(), link); !errors.Is(err, port.ErrProviderFailed) {
+20 -20
View File
@@ -24,7 +24,7 @@ var (
ErrAccountNotFound = errors.New("没有这个用户名的账号")
)
// 注册、登录和修改密码都要做一次 argon2id 计算:额度既防止猜密码,也防止耗尽计算槽位。
// 注册、登录和修改密码都要做一次 argon2id 计算,所以额度除了防止猜密码,也防止耗尽计算槽位。
const (
registerBurst = 5
registerRefill = 10 * time.Minute
@@ -49,7 +49,7 @@ func accountOf(a port.Account) Account {
return Account{Username: a.Username, Email: a.Email, GitHub: a.GitHubID != 0}
}
// 删除会话之后,用这些会话的票据建立的房间连接随之断开:网关立即通知各实例,通知丢失时最迟在网关的一个核对周期之后断开。
// 删除会话之后,用这些会话的票据建立的房间连接随之断开。网关立即通知各实例,通知丢失时,连接最迟在网关的一个核对周期之后断开。
type Accounts struct {
members port.Members
site port.Site
@@ -73,7 +73,7 @@ func (a *Accounts) Account(ctx context.Context, memberID string) (account Accoun
return Account{Username: profile.Username, Email: profile.Email, GitHub: profile.GitHub}, profile.Registered(), nil
}
// 给游客设置用户名和密码,成员和它的会话都不变;只绑定了 GitHub 的账号也用它补设用户名和密码,见 addCredentials。
// 给游客设置用户名和密码,成员和成员的会话都不变。只绑定了 GitHub 的账号也用 Register 补设用户名和密码,见 addCredentials。
// token 是发出请求的成员 token,username 和 password 已经按协议校验过格式。
func (a *Accounts) Register(ctx context.Context, memberID, token, clientIP, username, password string) (Account, error) {
profile, err := a.members.AccountProfile(ctx, memberID)
@@ -106,8 +106,8 @@ func (a *Accounts) Register(ctx context.Context, memberID, token, clientIP, user
return Account{Username: username, Email: profile.Email, GitHub: profile.GitHub}, nil
}
// 拿到会话的人不能抢先给只绑定了 GitHub 的账号设成自己的密码:发出请求的会话要在 10 分钟内用账号绑定的 GitHub 确认过身份
// (GitHub.Exchange 的 verify)。补设之后,账号除了这个会话,其他会话都失效,与修改密码相同:对方随之下线。
// 拿到会话的人不能抢先给只绑定了 GitHub 的账号设成自己的密码,所以发出请求的会话要在 10 分钟内用账号绑定的 GitHub 确认过身份
// (GitHub.Exchange 的 verify)。补设之后,账号除了这个会话,其他会话都失效,这与修改密码相同,对方会随之下线。
// 补设不是注册新账号,注册关闭时照样可以。
func (a *Accounts) addCredentials(ctx context.Context, memberID, token, clientIP, username, password, email string) (Account, error) {
tokenHash := hashSecret(token)
@@ -125,26 +125,26 @@ func (a *Accounts) addCredentials(ctx context.Context, memberID, token, clientIP
if err != nil {
return Account{}, err
}
// 失败时(比如用户名已被占用)确认保留,换个用户名可以重试;同一个账号只能补设一次,由存储保证。
// 失败时(比如用户名已被占用)GitHub 确认仍然保留,换个用户名可以重试。同一个账号只能补设一次,由存储保证。
deleted, err := a.members.AddCredentials(ctx, memberID, username, hash, tokenHash)
if err != nil {
return Account{}, err
}
a.gateway.RevokeSessions(ctx, deleted)
// 账号已经有了用户名,这次确认再也用不上:删不掉也只是等它过期,不影响补设的结果。
// 账号已经有了用户名,这次确认再也用不上了。删不掉也只是等确认过期,不影响补设的结果。
_ = a.verifications.DeleteVerification(ctx, tokenHash)
return Account{Username: username, Email: email, GitHub: true}, nil
}
// current 是发出请求的成员,currentToken 是它的成员 token。只有游客能登录:成功后签发账号的新会话,游客的这个会话随即失效。
// device 是这台设备此前登录时拿到的登录设备凭证,没有时为空字符串,已经按协议校验过格式。成功时返回这台设备此后要带上的凭证:
// 带来的凭证属于这个账号、签发之后密码也没变过时续期并原样返回,否则签发新的。
// current 是发出请求的成员,currentToken 是 current 的成员 token。只有游客能登录,成功后签发账号的新会话,游客的这个会话随即失效。
// device 是这台设备此前登录时拿到的登录设备凭证,没有时为空字符串,已经按协议校验过格式。成功时返回这台设备此后要带上的凭证。
// 带来的凭证属于这个账号,并且签发之后密码没变过,就续期并原样返回,否则签发新的。
func (a *Accounts) Login(ctx context.Context, current member.Member, currentToken, clientIP, username, password, device string) (Session, Account, string, error) {
if err := a.requireGuest(ctx, current.ID); err != nil {
return Session{}, Account{}, "", err
}
// 按 IP 限制一个来源的尝试次数,按用户名限制一个账号被尝试的次数:换着 IP 猜同一个账号也会受限。
// 带着这个账号的凭证时改按凭证计数:别人反复猜密码耗尽了用户名额度,账号主人的设备仍然能登录。
// 按 IP 限制一个来源的尝试次数,按用户名限制一个账号被尝试的次数,这样换着 IP 猜同一个账号也会受限。
// 带着这个账号的凭证时改按凭证计数,这样别人反复猜密码耗尽了用户名额度,账号主人的设备仍然能登录。
if err := a.take(ctx, "login:ip:"+quotaAddr(clientIP), loginIPBurst, loginIPRefill); err != nil {
return Session{}, Account{}, "", err
}
@@ -166,7 +166,7 @@ func (a *Accounts) Login(ctx context.Context, current member.Member, currentToke
if !known {
device, _ = newSecret()
}
// 先存凭证再签发会话:存不了时游客的会话还在,可以重试;签发会话失败时留下的凭证到期自行失效。
// 先存凭证再签发会话。凭证存不了时,游客的会话还在,可以重试。签发会话失败时,留下的凭证到期自行失效。
if err := a.devices.SaveDevice(ctx, hashSecret(device), account.Member.ID, passwordStamp(account.PasswordHash), loginDeviceTTL); err != nil {
return Session{}, Account{}, "", err
}
@@ -202,14 +202,14 @@ func (a *Accounts) knownDevice(ctx context.Context, device, username string) (bo
return account.Member.ID == memberID && bytes.Equal(stamp, passwordStamp(account.PasswordHash)), nil
}
// 登录设备凭证要在改密码之后失效,所以绑定签发时的密码:否则知道旧密码的人可以反复登录先囤下一批凭证,
// 登录设备凭证要在改密码之后失效,所以凭证绑定签发时的密码。否则知道旧密码的人可以反复登录,先囤下一批凭证,
// 等账号改了密码,每个凭证仍各有一份独立的猜测额度,按用户名的额度就被绕过了。凭证只存密码哈希的指纹,不存哈希本身。
func passwordStamp(passwordHash string) []byte {
sum := sha256.Sum256([]byte(passwordHash))
return sum[:16]
}
// 只有游客能登录:已经登录了账号的设备要先退出。
// 只有游客能登录,已经登录了账号的设备要先退出。
func (a *Accounts) requireGuest(ctx context.Context, memberID string) error {
if _, registered, err := a.Account(ctx, memberID); err != nil {
return err
@@ -219,8 +219,8 @@ func (a *Accounts) requireGuest(ctx context.Context, memberID string) error {
return nil
}
// 给发出请求的游客签发 account 的新会话,游客的这个会话随即失效。account.PasswordHash 是校验时读到的密码哈希,
// 此后密码被修改或重置过时返回 port.ErrAccountPasswordChanged,由调用方按各自的登录方式告诉用户。
// 给发出请求的游客签发 account 的新会话,游客的这个会话随即失效。account.PasswordHash 是校验时读到的密码哈希。
// 如果此后密码被修改或重置过,返回 port.ErrAccountPasswordChanged,由调用方按各自的登录方式告诉用户。
func (a *Accounts) signIn(ctx context.Context, currentToken string, account port.Account) (Session, error) {
token, hash := newSecret()
guest := hashSecret(currentToken)
@@ -237,7 +237,7 @@ func (a *Accounts) signIn(ctx context.Context, currentToken string, account port
func (a *Accounts) verify(ctx context.Context, username, password string) (port.Account, error) {
account, err := a.members.AccountByUsername(ctx, username)
// 空的密码哈希表示没有设置过密码,任何密码都不对;和用户名不存在一样做一次同样的计算。
// 空的密码哈希表示没有设置过密码,任何密码都不对。这时和用户名不存在一样,也做一次同样的计算。
if errors.Is(err, port.ErrMemberNotFound) || (err == nil && account.PasswordHash == "") {
dummy, err := dummyPasswordHash()
if err != nil {
@@ -291,7 +291,7 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
if err != nil {
return err
}
// 校验之后密码可能被重置或被别的设备改过:发出请求的会话已经被删掉时按 token 失效处理,同一个会话先改过时当前密码已经不对。
// 校验之后密码可能已经变了。被重置或被别的设备改过时,发出请求的会话已经被删掉,按 token 失效处理。同一个会话先改过时,当前密码已经不对。
deleted, err := a.members.ChangePassword(ctx, memberID, account.PasswordHash, hash, hashSecret(token))
switch {
case errors.Is(err, port.ErrSessionNotFound):
@@ -305,7 +305,7 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
return nil
}
// 修改密码、绑定和解绑 GitHub 都要输入当前密码:只凭一个会话不能更换登录凭证,否则拿到会话的人换上自己的凭证之后,
// 修改密码、绑定和解绑 GitHub 都要输入当前密码,不能只凭一个会话就更换登录凭证。否则拿到会话的人换上自己的凭证之后,
// 本人修改密码、后台重置密码都赶不走他。额度按成员计数,这几种操作共用。
func (a *Accounts) checkCurrentPassword(ctx context.Context, memberID, username, password string) (port.Account, error) {
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
+12 -12
View File
@@ -101,7 +101,7 @@ func ticketValid(t *testing.T, s *Service, ticket string) bool {
return err == nil
}
// 返回 token 当前所属的成员;token 已经失效时 ok 为 false。
// 返回 token 当前所属的成员。token 已经失效时 ok 为 false。
func tokenOwner(t *testing.T, store *porttest.Store, token string) (m member.Member, ok bool) {
t.Helper()
m, err := store.MemberByToken(t.Context(), hashSecret(token))
@@ -314,7 +314,7 @@ type deviceSave struct {
ttl time.Duration
}
// 记下每次保存的登录设备凭证;fail 不为 nil 时保存失败。
// 记下每次保存的登录设备凭证。fail 不为 nil 时保存失败。
type recordingDevices struct {
*porttest.Devices
saves []deviceSave
@@ -343,7 +343,7 @@ func deviceQuota(device string) string {
return "login:device:" + hex.EncodeToString(hashSecret(device))
}
// 账号当前密码哈希的指纹,即 SHA-256 的前 16 字节:凭证只存它,不存密码哈希本身。
// 账号当前密码哈希的指纹,即 SHA-256 的前 16 字节。凭证只存指纹,不存密码哈希本身。
func passwordStampOf(t *testing.T, store *porttest.Store, username string) string {
t.Helper()
account, err := store.AccountByUsername(t.Context(), username)
@@ -354,7 +354,7 @@ func passwordStampOf(t *testing.T, store *porttest.Store, username string) strin
return string(sum[:16])
}
// 登录成功时签发绑定账号的凭证;带着它再登录,扣的是凭证自己的额度,返回同一个凭证并重新计算 180 天的有效期。
// 登录成功时签发绑定账号的凭证。带着凭证再登录,扣的是凭证自己的额度,返回同一个凭证并重新计算 180 天的有效期。
func TestLoginIssuesDevice(t *testing.T) {
a, s, store, q, devices := newDeviceAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
@@ -413,7 +413,7 @@ func TestLoginWithDeviceWhileUsernameQuotaExhausted(t *testing.T) {
}
}
// 未知的、别的账号的凭证都当作没带:扣用户名额度,成功时签发这个账号的新凭证。
// 未知的、别的账号的凭证都当作没带,所以扣用户名额度,成功时签发这个账号的新凭证。
func TestLoginWithForeignDeviceUsesUsernameQuota(t *testing.T) {
a, s, store, q, devices := newDeviceAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
@@ -488,7 +488,7 @@ func TestLoginFailureIssuesNoDevice(t *testing.T) {
}
}
// 凭证要在签发会话之前存好:存不了时整个登录失败,游客的会话不变,可以重试。
// 凭证要在签发会话之前存好。凭证存不了时整个登录失败,游客的会话不变,可以重试。
func TestLoginFailsWhenDeviceCannotBeSaved(t *testing.T) {
a, s, store, _, devices := newDeviceAccounts(t)
registerAccount(t, a, s, "alice", "correct horse")
@@ -506,8 +506,8 @@ func TestLoginFailsWhenDeviceCannotBeSaved(t *testing.T) {
}
}
// 知道旧密码的人反复登录囤下的凭证,在修改、重置密码之后都当作没带:用户名额度用完时一律被限流。
// 用新密码登录成功时签发新的凭证;没改过密码的账号,凭证照常有效。
// 知道旧密码的人反复登录囤下的凭证,在修改、重置密码之后都当作没带,所以用户名额度用完时一律被限流。
// 用新密码登录成功时签发新的凭证。没改过密码的账号,凭证照常有效。
func TestLoginDeviceExpiresWhenPasswordChanges(t *testing.T) {
for _, tc := range []struct {
name string
@@ -731,7 +731,7 @@ func (noRevocation) PublishSessionsRevoked(context.Context, [][]byte) error {
return porttest.ErrRedisDown
}
// 会话已经删除,断开的通知发不出去时照常成功:用这些会话的票据建立的连接由网关定期核对凭证时断开。
// 会话已经删除时,即使断开的通知发不出去,操作也照常成功。用这些会话的票据建立的连接,会在网关定期核对凭证时断开。
func TestAccountOperationsSucceedWhenRevocationFails(t *testing.T) {
store := porttest.New()
node := porttest.NewRedis().Node("a")
@@ -911,7 +911,7 @@ func verifiedByGitHub(t *testing.T, a *Accounts, s Session) {
}
}
// 只绑定了 GitHub 的账号补设用户名和密码,不是注册新账号:注册关闭时也可以。
// 只绑定了 GitHub 的账号补设用户名和密码不是注册新账号,所以注册关闭时也可以。
func TestGitHubAccountSetsPasswordWhileRegistrationClosed(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
gh := githubAccount(t, s, store, 42)
@@ -942,7 +942,7 @@ func TestGitHubAccountSetsPasswordWhileRegistrationClosed(t *testing.T) {
}
}
// 拿到只绑定了 GitHub 的账号的一个会话(这里是兑换了配对码),不能抢先设成自己的用户名和密码:要先用账号绑定的 GitHub 确认,
// 有人拿到了只绑定 GitHub 的账号的一个会话(这里是兑换了配对码),也不能抢先设成自己的用户名和密码。补设之前要先用账号绑定的 GitHub 确认,
// 确认只对确认时的那个会话有效。本人确认之后设置,其他设备全部退出,对方随之下线。
func TestGitHubAccountCredentialsNeedGitHubVerification(t *testing.T) {
a, s, store, _, revoked := newAccounts(t)
@@ -994,7 +994,7 @@ func TestGitHubAccountCredentialsNeedGitHubVerification(t *testing.T) {
}
}
// 存储中的空密码哈希表示没有设置过密码,不是格式错误的哈希:按密码错误处理,不报内部错误。
// 存储中的空密码哈希表示没有设置过密码,不是格式错误的哈希,所以按密码错误处理,不报内部错误。
func TestEmptyPasswordHashIsWrongPassword(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := createMember(t, s)
+16 -15
View File
@@ -25,15 +25,16 @@ const (
githubGrantTTL = time.Minute
// 用 GitHub 确认身份之后,要在这段时间内补设用户名和密码。
githubVerificationTTL = 10 * time.Minute
// 每次发起授权都在 Redis 中保存一个 10 分钟的 state,额度与登录相同。
// 每次发起授权都会在 Redis 中保存一个 10 分钟的 state,所以按 IP 限制发起的次数。额度的大小和登录按 IP 的额度相同,但分开计数。
githubAuthorizeBurst = 10
githubAuthorizeRefill = time.Minute
)
// 后台配置了 GitHub OAuth App 才能使用,没有配置时 Authorize、Callback、Exchange 和 Unbind 都返回 ErrGitHubDisabled。它们每次都重新读取
// 配置,不缓存:后台的修改在所有实例上立即生效,实例之间不必同步。流程:Authorize 记下意图、客户端、nonce 和 PKCE 的 verifier,返回
// GitHub 的授权地址;GitHub 回调时 Callback 换取用户 ID,生成一次性的兑换码,GitHub 没有授权时 Cancel 作废 state,两者都返回记下的
// 客户端,回调跳回它;发起授权的客户端用兑换码和它保存的 nonce 调用 Exchange,按意图登录、注册、绑定或确认身份。
// 后台配置了 GitHub OAuth App 才能使用,没有配置时 Authorize、Callback、Exchange 和 Unbind 都返回 ErrGitHubDisabled。
// 这几个方法每次都重新读取配置,不缓存,这样后台的修改在所有实例上立即生效,实例之间不必同步。
// 流程:Authorize 记下意图、客户端、nonce 和 PKCE 的 verifier,返回 GitHub 的授权地址。GitHub 回调时,Callback 换取用户 ID,生成一次性的兑换码。
// GitHub 没有授权时,Cancel 作废 state。Callback 和 Cancel 都返回记下的客户端,回调跳回这个客户端。
// 发起授权的客户端用兑换码和自己保存的 nonce 调用 Exchange,按意图登录、注册、绑定或确认身份。
type GitHub struct {
accounts *Accounts
apps port.GitHubOAuthApps
@@ -45,7 +46,7 @@ func NewGitHub(accounts *Accounts, apps port.GitHubOAuthApps, oauth port.GitHubO
return &GitHub{accounts: accounts, apps: apps, oauth: oauth, logins: logins}
}
// 没有配置时返回空字符串。不提供读取 Client Secret 的方法:它只用来向 GitHub 换取令牌。
// 没有配置时返回空字符串。不提供读取 Client Secret 的方法,因为 Client Secret 只用来向 GitHub 换取令牌。
func (g *GitHub) ClientID(ctx context.Context) (string, error) {
app, err := g.apps.GitHubOAuthApp(ctx)
if err != nil {
@@ -115,10 +116,10 @@ func (g *GitHub) Authorize(ctx context.Context, memberID, clientIP string, inten
return g.oauth.AuthorizeURL(app, state, verifier), nil
}
// 只换取 GitHub 用户 ID,不修改账号,返回发起授权的客户端和一次性的兑换码。登录、注册和绑定都留给 Exchange:回调请求不带成员 token
// 和 nonce,谁都可以把自己发起的授权地址发给别人,在回调时绑定,别人一授权,他的 GitHub 就绑到了发起者的账号上。
// 取出 state 之后出错时也返回发起授权的客户端,失败的结果同样要跳回它。授权进行中后台换了 OAuth App 时,GitHub 不认原来签发的
// 授权码,返回 ErrGitHubFailed。
// 只换取 GitHub 用户 ID,不修改账号,返回发起授权的客户端和一次性的兑换码。登录、注册和绑定都留给 Exchange,因为回调请求不带成员 token
// 和 nonce。如果在回调时就绑定,谁都可以把自己发起的授权地址发给别人,别人一授权,别人的 GitHub 就绑到了发起者的账号上。
// 取出 state 之后出错时也返回发起授权的客户端,因为失败的结果同样要跳回这个客户端。授权进行中后台换了 OAuth App 时,GitHub 不认原来签发的
// 授权码,Callback 返回 ErrGitHubFailed。
func (g *GitHub) Callback(ctx context.Context, state, code string) (port.GitHubClient, string, error) {
request, err := g.logins.TakeRequest(ctx, hashSecret(state))
expired := errors.Is(err, port.ErrCodeNotFound)
@@ -157,13 +158,13 @@ func (g *GitHub) Cancel(ctx context.Context, state string) (port.GitHubClient, e
}
type GitHubResult struct {
// 登录了已经绑定这个 GitHub 账号的账号时才有:发出请求的游客会话已经换成这个账号的新会话。
// 登录了已经绑定这个 GitHub 账号的账号时才有 Session,这时发出请求的游客会话已经换成这个账号的新会话。
Session *Session
Member member.Member
Account Account
}
// current 和 currentToken 是发出请求的成员和它的成员 token,必须是发起授权的那个成员。
// current 是发出请求的成员,currentToken 是 current 的成员 token。current 必须是发起授权的那个成员。
func (g *GitHub) Exchange(ctx context.Context, current member.Member, currentToken, code, nonce string) (GitHubResult, error) {
if _, err := g.app(ctx); err != nil {
return GitHubResult{}, err
@@ -195,7 +196,7 @@ func (g *GitHub) login(ctx context.Context, current member.Member, currentToken
account, err := a.members.AccountByGitHub(ctx, githubID)
if err == nil {
session, err := a.signIn(ctx, currentToken, account)
// 账号的密码恰好在这时被修改或重置:修改删除了其他会话,这次登录也作废,重新登录即可。
// 账号的密码恰好在这时被修改或重置。修改删除了其他会话,这次登录也随之作废,重新登录即可。
if errors.Is(err, port.ErrAccountPasswordChanged) {
return GitHubResult{}, ErrGitHubLoginExpired
}
@@ -207,7 +208,7 @@ func (g *GitHub) login(ctx context.Context, current member.Member, currentToken
if !errors.Is(err, port.ErrMemberNotFound) {
return GitHubResult{}, err
}
// 没有绑定过的 GitHub 账号登录就是注册:绑到发出请求的游客上,成员和它的会话都不变。
// 没有绑定过的 GitHub 账号登录就是注册。GitHub 账号绑到发出请求的游客上,成员和成员的会话都不变。
settings, err := a.site.Settings(ctx)
if err != nil {
return GitHubResult{}, err
@@ -235,7 +236,7 @@ func (g *GitHub) bind(ctx context.Context, current member.Member, githubID int64
return GitHubResult{Member: current, Account: Account{Username: profile.Username, Email: profile.Email, GitHub: true}}, nil
}
// 只确认身份,不修改账号:GitHub 账号要是这个账号绑定的。确认记在发出请求的会话上,Accounts.Register 补设用户名和密码时要用。
// 只确认身份,不修改账号。GitHub 账号必须是这个账号绑定的那个。确认记在发出请求的会话上,Accounts.Register 补设用户名和密码时要用。
func (g *GitHub) verify(ctx context.Context, current member.Member, currentToken string, githubID int64) (GitHubResult, error) {
account, err := g.accounts.members.AccountByID(ctx, current.ID)
if err != nil {
+7 -7
View File
@@ -220,7 +220,7 @@ func TestGitHubBind(t *testing.T) {
}
}
// 兑换码只能由发起授权的那台设备、带着它保存的 nonce 兑换:别人把自己发起的授权发给你,你授权之后得到的兑换码对他也没用。
// 兑换码只能由发起授权的那台设备带着自己保存的 nonce 兑换。所以别人把自己发起的授权发给你,你授权之后得到的兑换码对他也没用。
func TestGitHubExchangeChecksNonceAndMember(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
@@ -289,7 +289,7 @@ func TestGitHubCallback(t *testing.T) {
}
}
// 发起授权的客户端记在 state 对应的记录里:回调成功、换取失败和 GitHub 没有授权时,都按记录跳回发起授权的客户端。
// 发起授权的客户端记在 state 对应的记录里。回调成功、换取失败和 GitHub 没有授权时,都按记录跳回发起授权的客户端。
func TestGitHubCallbackReturnsClient(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
@@ -359,7 +359,7 @@ func TestGitHubCodesExpire(t *testing.T) {
})
}
// 只凭会话不能更换登录凭证:绑定 GitHub 要输入当前密码,额度与修改密码共用。
// 只凭会话不能更换登录凭证,所以绑定 GitHub 要输入当前密码,额度与修改密码共用。
func TestGitHubBindRequiresCurrentPassword(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
@@ -420,7 +420,7 @@ func TestGitHubUnbind(t *testing.T) {
}
}
// 拿到账号一个会话的人(比如借用过的设备)不知道密码,就不能把账号的 GitHub 换成自己的:本人修改密码或后台重置之后,他无法再用 GitHub 登录回来。
// 拿到账号一个会话的人(比如借用过的设备)不知道密码,就不能把账号的 GitHub 换成自己的。这样本人修改密码或后台重置之后,他无法再用 GitHub 登录回来。
func TestGitHubCannotBeSwappedWithSessionAlone(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
@@ -512,7 +512,7 @@ func TestGitHubVerificationExpires(t *testing.T) {
})
}
// 读到账号之后、签发会话之前,密码被修改或重置:修改时删除了其他会话,这次登录不能留下新会话。
// 读到账号之后、签发会话之前,密码被修改或重置。修改时删除了其他会话,所以这次登录也不能留下新会话。
func TestGitHubLoginRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
@@ -566,8 +566,8 @@ func TestGitHubOAuthApp(t *testing.T) {
}
}
// 没有配置 OAuth App 时,发起授权、兑换和解绑都返回 ErrGitHubDisabled。授权进行中关闭了 GitHub 登录,回调也失败,
// 照样跳回发起授权的客户端;重新开启之后恢复。
// 没有配置 OAuth App 时,发起授权、兑换和解绑都返回 ErrGitHubDisabled。授权进行中关闭了 GitHub 登录时,回调也失败,
// 但照样跳回发起授权的客户端。重新开启之后恢复。
func TestGitHubDisabled(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
+6 -6
View File
@@ -60,7 +60,7 @@ func (s *Service) Authenticate(ctx context.Context, memberToken string) (member.
type Profile struct {
Name *string
// 用来计算头像哈希;已注册的账号同时保存它。空字符串表示清除。
// 用来计算头像哈希。已注册的账号还会同时保存邮箱本身。空字符串表示清除。
Email *string
}
@@ -84,7 +84,7 @@ func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile)
return m, nil
}
// memberToken 是发出请求的会话,票据属于这个会话:会话删除时票据随之失效。
// memberToken 是发出请求的会话,票据属于这个会话,所以会话删除时票据随之失效。
func (s *Service) IssueTicket(ctx context.Context, memberID, memberToken, roomID, password string) (string, error) {
r, err := s.rooms.FindRoom(ctx, roomID)
if err != nil {
@@ -102,7 +102,7 @@ func (s *Service) IssueTicket(ctx context.Context, memberID, memberToken, roomID
ticket, hash := newSecret()
err = s.tickets.CreateTicket(ctx, port.NewTicket{TokenHash: hash, RoomID: r.Info.ID, MemberID: memberID, PasswordHash: r.PasswordHash, SessionTokenHash: hashSecret(memberToken)})
if errors.Is(err, port.ErrPasswordChanged) {
// 刚才是按旧密码校验的;客户端重新输入密码后,按新密码校验。
// 刚才是按旧密码校验的。客户端重新输入密码后,会按新密码校验。
return "", ErrWrongPassword
}
if errors.Is(err, port.ErrSessionNotFound) {
@@ -115,7 +115,7 @@ func (s *Service) IssueTicket(ctx context.Context, memberID, memberToken, roomID
return ticket, nil
}
// 连接在网关中登记的凭证,与票据在存储中的哈希相同:网关定期拿它到存储中核对,不同的话仍然有效的连接也会被当作已撤销而断开。
// 连接在网关中登记的凭证,就是票据在存储中的哈希。网关定期拿凭证到存储中核对,两者不同的话,仍然有效的连接也会被当作已撤销而断开。
func TicketCredential(ticket string) []byte {
return hashSecret(ticket)
}
@@ -143,7 +143,7 @@ func (s *Service) EnterRoom(ctx context.Context, roomID, ticket string) (*realti
if holder.RoomID != r.Info.ID {
return nil, ErrInvalidTicket
}
// bot 只用 HTTP 接口:它不出现在在线列表中,重置 Key 之后也不会留下用旧 Key 建立的会话。
// bot 只用 HTTP 接口,这样 bot 不会出现在在线列表中,重置 Key 之后也不会留下用旧 Key 建立的会话。
if holder.Bot {
return nil, ErrBotSession
}
@@ -151,7 +151,7 @@ func (s *Service) EnterRoom(ctx context.Context, roomID, ticket string) (*realti
if err != nil {
return nil, err
}
// 作废时先删除票据、再通知断开:会话加入网关之前发出的通知它收不到,所以加入之后再确认一次票据。
// 作废票据时先删除票据,再通知断开。通知如果在会话加入网关之前发出,会话就收不到,所以加入之后要再确认一次票据。
if _, err := s.VerifyTicket(ctx, ticket); err != nil {
s.gateway.Leave(session)
return nil, err
+2 -2
View File
@@ -26,7 +26,7 @@ func newService(t *testing.T) (*Service, *porttest.Store) {
return New(store, store, store, newGateway(store, node, node)), store
}
// 没有调用 realtime.Start:这些测试不运行房间。
// 没有调用 realtime.Start,因为这些测试不运行房间。
func newGateway(store *porttest.Store, node *porttest.Node, bus port.Bus) *realtime.Gateway {
_, gateway := realtime.New(store, store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
return gateway
@@ -263,7 +263,7 @@ func TestEnterRoomJoinsTheStoredRoom(t *testing.T) {
t.Fatal(err)
}
// 测试中的网关不运行房间,加入在订阅房间频道之后失败,这里只检查订阅时的日志。
// 测试中的网关不运行房间,加入会在订阅房间频道之后失败,所以只检查订阅时的日志。
_, _ = s.EnterRoom(t.Context(), "hall", ticket)
var entry map[string]any
if err := json.Unmarshal(logs.Bytes(), &entry); err != nil {
+5 -5
View File
@@ -13,10 +13,10 @@ var ErrInvalidPairingCode = errors.New("配对码不对或已经过期")
const (
pairingTTL = 5 * time.Minute
// 生成配对码只占 Redis 的空间:额度防止一个账号在短时间内灌入大量配对码。
// 生成配对码只占 Redis 的空间,额度用来防止一个账号在短时间内灌入大量配对码。
pairingCreateBurst = 5
pairingCreateRefill = time.Minute
// 配对码有 32^8 ≈ 1.1e12 种,5 分钟就过期:按 IP 的额度与登录相同,足以让猜中的可能性忽略不计。
// 配对码有 32^8 ≈ 1.1e12 种,5 分钟就过期,所以按 IP 的额度与登录相同就足以让猜中的可能性忽略不计。
pairingRedeemBurst = 10
pairingRedeemRefill = time.Minute
// 新配对码与有效的配对码撞上的可能性约为万亿分之一,重试几次足够。
@@ -28,7 +28,7 @@ type PairingCode struct {
ExpiresAt time.Time
}
// token 是发出请求的会话:配对码属于它,它被删除(退出登录、修改或重置密码)时配对码随之作废。
// token 是发出请求的会话,配对码属于这个会话。会话被删除(退出登录、修改或重置密码)时,配对码随之作废。
func (a *Accounts) CreatePairingCode(ctx context.Context, memberID, token string) (PairingCode, error) {
if _, registered, err := a.Account(ctx, memberID); err != nil {
return PairingCode{}, err
@@ -53,7 +53,7 @@ func (a *Accounts) CreatePairingCode(ctx context.Context, memberID, token string
return PairingCode{}, errors.New("生成的配对码都与有效的配对码重复")
}
// 兑换配对码就是登录:与 Login 相同,只有游客能兑换,成功后游客的这个会话随即失效。code 已经按协议校验过格式。
// 兑换配对码就是登录。与 Login 相同,只有游客能兑换,成功后游客的这个会话随即失效。code 已经按协议校验过格式。
func (a *Accounts) RedeemPairingCode(ctx context.Context, current member.Member, currentToken, clientIP, code string) (Session, Account, error) {
if err := a.requireGuest(ctx, current.ID); err != nil {
return Session{}, Account{}, err
@@ -68,7 +68,7 @@ func (a *Accounts) RedeemPairingCode(ctx context.Context, current member.Member,
if err != nil {
return Session{}, Account{}, err
}
// 先读密码哈希,再确认生成配对码的会话还在:修改或重置密码在此之前提交时,那个会话已经被删除;在此之后提交时,
// 先读密码哈希,再确认生成配对码的会话还在。修改或重置密码要是在确认之前提交,那个会话已经被删除。要是在确认之后提交,
// 签发会话会因为密码哈希变了而失败。
account, err := a.members.AccountByID(ctx, pairing.MemberID)
if err != nil {
+2 -2
View File
@@ -147,7 +147,7 @@ func TestPairingRejected(t *testing.T) {
}
}
// 让其他设备退出的办法是修改密码或者重置密码:此前生成的配对码要随生成它的会话一起作废,否则拿到配对码的人还能登录进来。
// 让其他设备退出的办法是修改密码或者重置密码,所以此前生成的配对码要随生成它的会话一起作废,否则拿到配对码的人还能登录进来。
func TestPairingCodeDiesWithItsSession(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
@@ -216,7 +216,7 @@ func TestPairGitHubOnlyAccount(t *testing.T) {
}
}
// 读到账号的密码哈希之后、签发会话之前,密码被修改或重置:修改时删除了其他会话,这次兑换不能留下新会话。
// 读到账号的密码哈希之后、签发会话之前,密码被修改或重置。修改时删除了其他会话,所以这次兑换也不能留下新会话。
func TestRedeemRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
+4 -4
View File
@@ -14,7 +14,7 @@ import (
"golang.org/x/crypto/argon2"
)
// 采用 OWASP 密码存储建议中 argon2id 的最低配置:任何成员都能请求换取票据,而每次校验都要分配这么多内存。
// 采用 OWASP 密码存储建议中 argon2id 的最低配置,因为任何成员都能请求换取票据,而每次校验都要分配这么多内存。
const (
argonTime = 2
argonMemory = 19 * 1024 // KiB
@@ -27,7 +27,7 @@ var passwordPrefix = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$", argon2.Versio
var errMalformedPasswordHash = errors.New("密码哈希的格式不正确")
// 每次计算都要分配 argonMemory 的内存,而创建临时房间、换取票据和登录任何人都能请求:不限制并发时,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。
// 每次计算都要分配 argonMemory 的内存,而创建临时房间、换取票据和登录任何人都能请求。不限制并发的话,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。
var argonSlots = make(chan struct{}, runtime.GOMAXPROCS(0))
func idKey(ctx context.Context, password string, salt []byte) ([]byte, error) {
@@ -53,13 +53,13 @@ func HashPassword(ctx context.Context, password string) (string, error) {
return passwordPrefix + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(key), nil
}
// 用户名不存在时拿它做一次同样的计算,响应时间不暴露用户名是否存在。密码是随机值,不会与任何人输入的密码匹配。
// 用户名不存在时拿 dummyPasswordHash 做一次同样的计算,这样响应时间不会暴露用户名是否存在。密码是随机值,不会与任何人输入的密码匹配。
var dummyPasswordHash = sync.OnceValues(func() (string, error) {
password, _ := newSecret()
return HashPassword(context.Background(), password)
})
// 只接受按当前参数生成的哈希:存储中的密码哈希都由 HashPassword 生成。
// 只接受按当前参数生成的哈希,因为存储中的密码哈希都由 HashPassword 生成。
func checkPassword(ctx context.Context, hash, password string) (bool, error) {
encoded, ok := strings.CutPrefix(hash, passwordPrefix)
if !ok {
+1 -1
View File
@@ -51,7 +51,7 @@ func occupyArgonSlots(t *testing.T) {
for range cap(argonSlots) {
argonSlots <- struct{}{}
}
// 归还名额时不阻塞:实现在 ctx 结束之后仍去计算时,会在结束时替这里归还一个名额;阻塞地归还会让测试一直挂到包超时,
// 归还名额时不阻塞。如果实现在 ctx 结束之后仍去计算,计算结束时会替测试归还一个名额,这时阻塞地归还会让测试一直挂到包超时,
// 而不是报出错误。
t.Cleanup(func() {
for range cap(argonSlots) {
Loaded 100 of 391 files, more files were not shown because too many files have changed in this diff. Show more