Compare commits

..
2 Commits
Author SHA1 Message Date
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
325 changed files with 1141 additions and 1127 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,
+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
+7 -7
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
@@ -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) {
+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)
}
+1 -1
View File
@@ -70,7 +70,7 @@ func (s *server) adminListMedia(ctx context.Context, c call) (any, error) {
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)
}
+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
@@ -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
+2 -2
View File
@@ -18,7 +18,7 @@ var _ port.MediaLibrary = (*Store)(nil)
var mediaSorts = map[port.MediaSort]string{port.MediaByStored: "stored", port.MediaByIdle: "idle", port.MediaBySize: "size"}
// 搜索的文字按字面匹配:LIKE 的通配符和转义符都要转义。
// 搜索的文字按字面匹配,所以 LIKE 的通配符和转义符都要转义。
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
func (s *Store) ListStoredMedia(ctx context.Context, q port.MediaQuery) (port.MediaPage, error) {
@@ -32,7 +32,7 @@ func (s *Store) ListStoredMedia(ctx context.Context, q port.MediaQuery) (port.Me
return err
}
page.Total, page.TotalBytes = int(count.Total), count.TotalBytes
// 页码来自客户端,可能大到算偏移量时溢出;超出最后一页时也不必再查。
// 页码来自客户端,可能大到算偏移量时溢出,所以偏移量超过总数时直接返回,不再查询。
if int64(q.Page-1) > count.Total/port.MediaPageSize {
return nil
}
+4 -4
View File
@@ -95,15 +95,15 @@ func mediaKeys(items []port.StoredMedia) []string {
return keys
}
// 120 首:存储时间和最近播放时间各不相同,其中 10 首没有播放过;每 3 首大小相同,要靠曲目 ID 分出先后。
// 有 3 首的标题含 50%_off;另外两首只在 % 或 _ 被当作通配符时才会匹配它。
// 共 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 排才看得出来。
// 曲目 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)),
@@ -128,7 +128,7 @@ func TestStoredMediaPages(t *testing.T) {
}
return page
}
// 每一页都报告全部的数量和大小;翻过最后一页是空的。
// 每一页都报告全部的数量和大小。翻过最后一页得到的是空页。
pages := func(sort port.MediaSort) []port.StoredMedia {
t.Helper()
var items []port.StoredMedia
+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)
}
+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") {
+6 -6
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
}
@@ -46,7 +46,7 @@ func New(netease port.NetEaseSource, qq port.Source, bilibili port.BilibiliSourc
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 {
@@ -87,7 +87,7 @@ 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, "歌单内的歌曲")
@@ -135,7 +135,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
@@ -300,7 +300,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 {
+1 -1
View File
@@ -312,7 +312,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) {
+2 -2
View File
@@ -40,7 +40,7 @@ func avatarHash(email string) string {
return hex.EncodeToString(sum[:])
}
// 配对码要能手动输入:去掉了容易认错的 0、O、1、I,剩下 32 个字符,每个字符正好对应 5 位随机值。
// 配对码要能手动输入,所以去掉了容易认错的 0、O、1、I。剩下 32 个字符,每个字符正好对应 5 位随机值。
var pairingEncoding = base32.NewEncoding("23456789ABCDEFGHJKLMNPQRSTUVWXYZ").WithPadding(base32.NoPadding)
// 40 位随机值,8 个字符。
@@ -50,7 +50,7 @@ func newPairingCode() string {
return pairingEncoding.EncodeToString(b)
}
// 后台重置密码时生成:96 位随机值的 base64url 编码,共 16 个字符,方便转告用户。
// 后台重置密码时生成。新密码是 96 位随机值的 base64url 编码,共 16 个字符,方便转告用户。
func newPassword() string {
b := make([]byte, 12)
rand.Read(b)
+1 -1
View File
@@ -44,7 +44,7 @@ func (s *Service) ListRooms(ctx context.Context) ([]Room, error) {
return s.listed(ctx, records)
}
// ref 必须是 UUID 或合法别名的形式:存储按形式区分两者,而且含有 U+0000 的文本会让 PostgreSQL 报错。
// ref 必须是 UUID 或合法别名的形式,因为存储按形式区分 UUID 和别名,而且含有 U+0000 的文本会让 PostgreSQL 报错。
func (s *Service) FindRoom(ctx context.Context, ref string) (Room, error) {
r, err := s.rooms.FindRoom(ctx, ref)
if err != nil {
+2 -2
View File
@@ -27,7 +27,7 @@ func (l *Library) List(ctx context.Context, q port.MediaQuery) (port.MediaPage,
return l.media.ListStoredMedia(ctx, q)
}
// 逐个删除:正在房间里使用的放进 inUse,不存在的忽略,其他错误立即返回,已经删掉的不会恢复。
// 逐个删除。正在房间里使用的放进 inUse,不存在的忽略。遇到其他错误立即返回,已经删掉的不会恢复。
func (l *Library) DeleteMany(ctx context.Context, keys []string) (deleted, inUse []string, err error) {
for _, key := range keys {
switch err := l.Delete(ctx, key); {
@@ -42,7 +42,7 @@ func (l *Library) DeleteMany(ctx context.Context, keys []string) (deleted, inUse
return deleted, inUse, nil
}
// 先删除对象,再清除准备结果:中途失败时准备结果还在,后台仍能看到这首歌并重试;
// 先删除对象,再清除准备结果。这样中途失败时准备结果还在,后台仍能看到这首歌并重试。
// 音频已经不在的准备结果也不会被使用。
func (l *Library) Delete(ctx context.Context, key string) error {
m, ok, err := l.media.StoredMedia(ctx, key)
+1 -1
View File
@@ -15,7 +15,7 @@ func Key(link string) string {
return hex.EncodeToString(sum[:])
}
// 链接和 B 站的歌的对象都在 link/ 下:对象存储的匿名读取策略、nginx 和 Traefik 都只放行这里的对象。
// 链接和 B 站的歌的对象都在 link/ 下,因为对象存储的匿名读取策略、nginx 和 Traefik 都只放行 link/ 下的对象。
func AudioObject(key string) string { return "link/" + key + "/audio.mp3" }
func CoverObject(key, ext string) string { return "link/" + key + "/cover." + ext }
+6 -6
View File
@@ -47,8 +47,8 @@ func NewPlayback(sources map[track.Source]port.PlayURLs, usage port.TrackUsage)
return &Playback{sources: sources, usage: usage, resolved: map[track.Track]resolvedURL{}}
}
// 只为至少出现在一个房间的条目中的歌解析:/media 不需要凭证,登录的账号不能被用来解析任意的歌。这项检查每次都做,
// 通过之后才复用 1 分钟内取到的地址。返回的地址总是 https:页面是 https 的,浏览器会拦截 http 的音频。
// 只为至少出现在一个房间的条目中的歌解析。/media 不需要凭证,不能让登录的账号被用来解析任意的歌。这项检查每次都做,
// 通过之后才复用 1 分钟内取到的地址。返回的地址总是 https,因为页面是 https 的,浏览器会拦截 http 的音频。
func (p *Playback) URL(ctx context.Context, t track.Track, now time.Time) (string, error) {
source, ok := p.sources[t.Source]
if !ok {
@@ -64,9 +64,9 @@ func (p *Playback) URL(ctx context.Context, t track.Track, now time.Time) (strin
if target, ok := p.reuse(t, now); ok {
return target, nil
}
// 合并的解析不随发起它的请求取消:同时等待的其他请求还要用它的结果。
// 合并的解析不随发起它的请求取消,因为同时等待的其他请求还要用解析的结果。
result := p.calls.DoChan(string(t.Source)+"/"+t.ID, func() (v any, err error) {
// 解析在独立的 goroutine 中执行,panic 回不到处理请求的 goroutine,net/http 无法恢复它,会让进程崩溃。
// 解析在独立的 goroutine 中执行,panic 回不到处理请求的 goroutine。net/http 无法恢复这样的 panic,进程会崩溃。
defer func() {
if r := recover(); r != nil {
err = &panicError{value: r, stack: debug.Stack()}
@@ -82,7 +82,7 @@ func (p *Playback) URL(ctx context.Context, t track.Track, now time.Time) (strin
case r := <-result:
var panicked *panicError
if errors.As(r.Err, &panicked) {
// 音源实现中的 panic 是程序缺陷,不是音源出错:交给调用方当作内部错误记录。
// 音源实现中的 panic 是程序缺陷,不是音源出错,所以交给调用方当作内部错误记录。
return "", panicked
}
if r.Err != nil {
@@ -123,7 +123,7 @@ func (e *panicError) Error() string {
return fmt.Sprintf("解析播放地址时 panic:%v\n%s", e.value, e.stack)
}
// 只替换 scheme,其余部分原样保留:签名过的地址重新编码之后可能失效。
// 只替换 scheme,其余部分原样保留,因为签名过的地址重新编码之后可能失效。
func secure(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
+1 -1
View File
@@ -35,7 +35,7 @@ func (p *playURLs) PlayURL(ctx context.Context, id string) (string, error) {
if _, ok := ctx.Deadline(); !ok {
return "", errors.New("解析没有期限")
}
// 像真实的音源那样:请求的 ctx 结束了,解析就失败。
// 像真实的音源那样,请求的 ctx 结束了,解析就失败。
if err := ctx.Err(); err != nil {
return "", err
}
+5 -5
View File
@@ -11,10 +11,10 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
// 接管房间的期限是 10 秒,检查链接和 B 站的歌的音频只占用其中一部分,对象存储变慢时接管仍能完成。
// 接管房间的期限是 10 秒,检查链接和 B 站的歌的音频只占用其中一部分,这样对象存储变慢时接管仍能完成。
const restoreCheckTimeout = 5 * time.Second
// Tracks 读取准备结果时,链接和 B 站的歌还要求音频仍在对象存储中:音频被清理或丢失之后,这首歌要重新下载。
// Tracks 读取准备结果时,链接和 B 站的歌还要求音频仍在对象存储中。音频被清理或丢失之后,这首歌要重新下载。
type Tracks struct {
port.Tracks
store port.MediaStore
@@ -24,7 +24,7 @@ func NewTracks(tracks port.Tracks, store port.MediaStore) *Tracks {
return &Tracks{Tracks: tracks, store: store}
}
// 检查对象存储失败时返回错误,调用方稍后重试,不当作没有结果:否则对象存储短暂不可用时,已经存储的歌都会重新下载。
// 检查对象存储失败时返回错误,由调用方稍后重试,不当作没有结果。否则对象存储短暂不可用时,已经存储的歌都会重新下载。
func (t *Tracks) PreparedTrack(ctx context.Context, tr track.Track) (port.PreparedTrack, bool, error) {
p, ok, err := t.Tracks.PreparedTrack(ctx, tr)
if err != nil || !ok || !Stored(tr.Source) {
@@ -40,7 +40,7 @@ func (t *Tracks) PreparedTrack(ctx context.Context, tr track.Track) (port.Prepar
return p, true, nil
}
// 只检查待播和等待队列:正在播放的歌已经开播。
// 只检查待播和等待队列,因为正在播放的歌已经开播了。
type Snapshots struct {
port.RoomSnapshots
store port.MediaStore
@@ -51,7 +51,7 @@ func NewSnapshots(snapshots port.RoomSnapshots, store port.MediaStore, logger *s
return &Snapshots{RoomSnapshots: snapshots, store: store, logger: logger}
}
// 检查出错的条目按曲目表恢复:对象存储不可用时房间仍要能被接管。任期已经加 1,所以这里不再返回错误。
// 检查出错的条目按曲目表恢复,因为对象存储不可用时房间仍要能被接管。任期已经加 1,所以检查出错时不再返回错误。
func (s *Snapshots) TakeRoom(ctx context.Context, id string) (uint32, room.Snapshot, error) {
term, snap, err := s.RoomSnapshots.TakeRoom(ctx, id)
if err != nil {
+1 -1
View File
@@ -17,7 +17,7 @@ const (
maxPlaylists = 50
maxItems = 1000
maxNameLength = 50
// 收藏可能要向音源查询元数据:额度防止批量请求让共用的音源账号被平台限流。
// 收藏可能要向音源查询元数据。这份额度防止批量请求让共用的音源账号被平台限流。
addBurst = 30
addRefill = 2 * time.Second
)
+1 -1
View File
@@ -78,7 +78,7 @@ func (q *accountQuota) Take(_ context.Context, key string, burst int, refill tim
return 0, nil
}
// 短链接到解析结果;不在表里的短链接解析失败。
// 从短链接到解析结果的映射。不在表里的短链接解析失败。
type shortLinks map[string]string
func (l shortLinks) Resolve(_ context.Context, link string) (string, error) {
+2 -2
View File
@@ -14,7 +14,7 @@ const shareIDBytes = 16
var shareIDLength = base64.RawURLEncoding.EncodedLen(shareIDBytes)
// 开启分享和重新生成是同一个操作:每次都换一个新的分享 ID,旧链接随即失效。
// 开启分享和重新生成是同一个操作。每次都换一个新的分享 ID,旧链接随即失效。
func (s *Service) Share(ctx context.Context, memberID, id string) (string, error) {
if err := s.registered(ctx, memberID); err != nil {
return "", err
@@ -63,7 +63,7 @@ func (s *Service) CopyShared(ctx context.Context, memberID, shareID, name string
return p, err
}
// 分享 ID 取自地址,可能是任意文本:不像生成出来的就不必查存储,NUL 之类的字符也就不会到达数据库。
// 分享 ID 取自地址,可能是任意文本。不像生成出来的分享 ID 就不必查存储,这样 NUL 之类的字符也不会到达数据库。
func validShareID(id string) bool {
if len(id) != shareIDLength {
return false
+1 -1
View File
@@ -16,7 +16,7 @@ type APIKeyStatus struct {
CreatedAt time.Time
}
// roomID 必须是常驻房间。每个房间至多一把 Key;bot 成员在第一次生成时创建,此后一直不变。
// roomID 必须是常驻房间。每个房间最多一把 Key。bot 成员在第一次生成 Key 时创建,之后一直不变。
type APIKeys interface {
RoomAPIKeyStatus(ctx context.Context, roomID string) (APIKeyStatus, error)
// 还没有 Key 时生成,已经有时旧 Key 立即失效。
+4 -4
View File
@@ -7,9 +7,9 @@ import (
)
type BilibiliSource interface {
// CoverURL 和 PageURL 留空:平台的图片有防盗链,封面在准备时下载存储;页面地址由调用方按 ID 拼出。
// 返回的 CoverURL 和 PageURL 都留空。平台的图片有防盗链,所以封面在准备时下载下来存储。页面地址由调用方按 ID 拼出。
TrackMeta(ctx context.Context, id string) (track.Meta, error)
// 读完文件之后调用 release 删除它们;返回错误时没有要删除的文件。视频不存在、需要付费、地区受限,
// 调用方读完文件之后调用 release 删除它们。返回错误时没有要删除的文件。视频不存在、需要付费、地区受限,
// 或者下载时发现音频超过 MaxAudioSize 时返回 *RejectedError。
Download(ctx context.Context, id string) (d BilibiliDownload, release func(), err error)
}
@@ -24,7 +24,7 @@ type BilibiliDownload struct {
CoverExt string
}
// 不是 B 站的链接时 ok 为 false、err 为 nil;是 B 站的链接但不是视频(番剧、直播、个人空间等)时返回 *RejectedError。
// 不是 B 站的链接时,ok 为 false,err 为 nil。是 B 站的链接但不是视频(番剧、直播、个人空间等)时,返回 *RejectedError。
type BilibiliLinks interface {
Recognize(ctx context.Context, link string) (id string, ok bool, err error)
}
@@ -36,7 +36,7 @@ type BilibiliQRLoginSource interface {
CredentialStatus(context.Context) (CredentialStatus, error)
}
// 只读本地文件,不访问网络。读完 mp3 之后调用 release 删除它;返回错误时没有要删除的文件。输入超过 MaxAudioSize 时返回 *RejectedError。
// 只读本地文件,不访问网络。调用方读完 mp3 之后调用 release 删除它。返回错误时没有要删除的文件。输入超过 MaxAudioSize 时返回 *RejectedError。
type Transcoder interface {
ToMP3(ctx context.Context, audio LinkFile) (mp3 LinkFile, release func(), err error)
}
+8 -8
View File
@@ -54,16 +54,16 @@ func (APIKeyReset) roomMessage() {}
type RoomInput struct {
Room string
// 发出输入的网关的实例 ID:owner 据此记录会话所在的网关。
// 发出输入的网关的实例 ID,owner 据此记录会话在哪个网关上。
Gateway string
// Input 与 Patch 恰好有一项不为空。
Input room.Input
Patch *InfoPatch
// 为真时,如果接收输入的实例上没有运行这个房间,就由它接管。
// 为真时,如果接收输入的实例上没有运行这个房间,就由这个实例接管。
Takeover bool
}
// nil 表示保持不变:管理接口只修改给出的字段,由房间按当前的名称和描述补全为 UpdateInfo。
// nil 表示保持不变。管理接口只修改给出的字段,房间用当前的名称和描述补上没给出的字段,组成 UpdateInfo。
type InfoPatch struct {
RequestID room.RequestID
Name *string
@@ -79,7 +79,7 @@ type (
}
InputDelivery struct {
// 带有网关一侧的追踪上下文,owner 在同一条 trace 中处理这条输入;网关一侧没有追踪时为 context.Background()。
// 带有网关一侧的追踪上下文,owner 在同一条 trace 中处理这条输入。网关一侧没有追踪时为 context.Background()。
Context context.Context
Input RoomInput
}
@@ -92,7 +92,7 @@ type (
Outcome PrepareOutcome
}
// 这些会话已经删除,用它们签发的票据建立的会话和事件流要以 4401 断开。
// SessionTokenHashes 对应的会话已经删除。用这些会话签发的票据建立的会话和事件流要以 4401 断开。
SessionsRevokedDelivery struct {
SessionTokenHashes [][]byte
}
@@ -112,17 +112,17 @@ func (SubscriptionLost) delivery() {}
type Bus interface {
// 订阅本实例的频道、成员的频道和准备结果的频道,返回时订阅已经生效。
SubscribeInstance(ctx context.Context) error
// 返回时订阅已经生效:此后发布到这个房间频道的消息都会收到。
// 返回时订阅已经生效,此后发布到这个房间频道的消息都会收到。
SubscribeRoom(ctx context.Context, roomID string) error
UnsubscribeRoom(ctx context.Context, roomID string) error
PublishRoom(ctx context.Context, roomID string, m RoomMessage) error
// 返回 false 表示目标实例没有订阅它的频道,例如已经停止运行。
// 返回 false 表示目标实例没有订阅自己的频道,例如已经停止运行。
SendInput(ctx context.Context, instanceID string, in RoomInput) (bool, error)
PublishMember(ctx context.Context, m member.Member) error
// 各实例都会收到,包括发布的实例。
PublishSessionsRevoked(ctx context.Context, sessionTokenHashes [][]byte) error
// 各实例都会收到,包括发布的实例。
PublishPrepared(ctx context.Context, o PrepareOutcome) error
// 按收到的顺序返回下一条消息,直到 ctx 结束;ctx 结束之后 Bus 不能再使用。只能由一个 goroutine 调用。
// 按收到的顺序返回下一条消息,直到 ctx 结束。ctx 结束之后 Bus 不能再使用。Receive 只能由一个 goroutine 调用。
Receive(ctx context.Context) (Delivery, error)
}
+2 -2
View File
@@ -9,11 +9,11 @@ import (
var ErrProviderFailed = errors.New("音乐平台没有正常响应,请稍后再试。")
// 房间宿主调用 Prepare 时,ctx 在房间停止运行时取消,结果不再交回房间;
// 房间宿主调用 Prepare 时,ctx 在房间停止运行时取消,结果不再交回房间。
// 实现要自己为每次对外请求设置期限。
type Preparer interface {
// 返回错误表示准备失败,错误的文本就是广播给房间的失败原因。
Prepare(ctx context.Context, t track.Track) (track.Meta, track.Prepared, error)
// 各实例收到 tracks.prepared 频道上的结果时调用,不能阻塞:调用方是接收实例间消息的 goroutine。
// 各实例收到 tracks.prepared 频道上的结果时调用 Finished。调用方是接收实例间消息的 goroutine,所以 Finished 不能阻塞。
Finished(o PrepareOutcome)
}
+4 -4
View File
@@ -8,11 +8,11 @@ import (
// Leases 是房间的 owner 租约,值是实例 ID。实现绑定在本实例上:取得、续期和释放的都是本实例的租约。
// 房间的在线人数附在租约上,与租约一起续期、一起过期。
type Leases interface {
// 租约不存在或已经属于本实例时取得它,在线人数记为 0;返回 false 表示租约属于其他实例。
// 租约不存在或已经属于本实例时取得租约,在线人数记为 0。返回 false 表示租约属于其他实例。
Acquire(ctx context.Context, roomID string, ttl time.Duration) (bool, error)
// 租约仍属于本实例时续期;返回 false 表示租约已不属于本实例。
// 租约仍属于本实例时续期。返回 false 表示租约已经不属于本实例。
Renew(ctx context.Context, roomID string, ttl time.Duration) (bool, error)
// 租约仍属于本实例时写入在线人数;返回 false 表示租约已不属于本实例。
// 租约仍属于本实例时写入在线人数。返回 false 表示租约已经不属于本实例。
SetOnline(ctx context.Context, roomID string, online int) (bool, error)
// 只删除属于本实例的租约。
Release(ctx context.Context, roomID string) error
@@ -27,7 +27,7 @@ type OnlineCounts interface {
// Liveness 是各实例的存活键。实现绑定在本实例上:Heartbeat 刷新的是本实例的存活键。
type Liveness interface {
// 返回 false 表示刷新之前存活键已经不存在:owner 可能已经让本实例的会话离开了在线列表。
// 返回 false 表示刷新之前存活键已经不存在。这时 owner 可能已经让本实例的会话离开了在线列表。
Heartbeat(ctx context.Context, ttl time.Duration) (bool, error)
Expired(ctx context.Context, instanceIDs []string) ([]string, error)
}
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff. Show more