From b986abf9374cd4bb162fe79a80ac2f6e4fb118f8 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 00:25:12 +0800 Subject: [PATCH] docs: reword comments and protocol descriptions for readability --- .golangci.yml | 14 +-- cmd/protocolgen/idl.go | 12 +- cmd/protocolgen/main.go | 5 +- cmd/protocolgen/openapi.go | 2 +- db/migrations/00001_schema.sql | 10 +- db/migrations/00003_member_accounts.sql | 4 +- db/migrations/00004_playlists.sql | 4 +- db/migrations/00005_listens.sql | 2 +- db/migrations/00006_playlist_shares.sql | 2 +- db/migrations/00007_member_github.sql | 2 +- db/queries/apikeys.sql | 4 +- db/queries/history.sql | 2 +- db/queries/listens.sql | 8 +- db/queries/members.sql | 14 +-- db/queries/playlists.sql | 8 +- db/queries/tickets.sql | 2 +- db/queries/tracks.sql | 2 +- internal/adapter/in/api/api.go | 14 +-- internal/adapter/in/api/api_test.go | 2 +- internal/adapter/in/api/clientip.go | 4 +- internal/adapter/in/api/convert.go | 8 +- internal/adapter/in/api/events.go | 14 +-- internal/adapter/in/api/events_test.go | 10 +- internal/adapter/in/api/github.go | 14 +-- internal/adapter/in/api/media.go | 2 +- internal/adapter/in/api/ws.go | 8 +- internal/adapter/in/api/ws_test.go | 6 +- internal/adapter/in/worker/worker.go | 10 +- internal/adapter/out/ffmpeg/ffmpeg.go | 10 +- internal/adapter/out/ffmpeg/ffmpeg_test.go | 14 +-- internal/adapter/out/postgres/apikeys_test.go | 2 +- internal/adapter/out/postgres/github_app.go | 2 +- .../adapter/out/postgres/github_app_test.go | 4 +- .../postgres/internal/query/apikeys.sql.go | 4 +- .../postgres/internal/query/history.sql.go | 2 +- .../postgres/internal/query/listens.sql.go | 8 +- .../postgres/internal/query/members.sql.go | 14 +-- .../postgres/internal/query/playlists.sql.go | 8 +- .../postgres/internal/query/tickets.sql.go | 2 +- .../out/postgres/internal/query/tracks.sql.go | 2 +- internal/adapter/out/postgres/listens.go | 2 +- internal/adapter/out/postgres/listens_test.go | 6 +- internal/adapter/out/postgres/media.go | 4 +- internal/adapter/out/postgres/media_test.go | 8 +- internal/adapter/out/postgres/members.go | 4 +- internal/adapter/out/postgres/members_test.go | 4 +- internal/adapter/out/postgres/migrate.go | 6 +- internal/adapter/out/postgres/migrate_test.go | 2 +- internal/adapter/out/postgres/playlists.go | 8 +- internal/adapter/out/postgres/ready.go | 2 +- internal/adapter/out/postgres/snapshot.go | 4 +- .../adapter/out/postgres/snapshot_test.go | 2 +- internal/adapter/out/postgres/store.go | 2 +- internal/adapter/out/queue/queue.go | 12 +- internal/adapter/out/redis/bus.go | 22 ++-- internal/adapter/out/redis/bus_test.go | 13 +- internal/adapter/out/redis/codec.go | 2 +- internal/adapter/out/redis/codes.go | 2 +- internal/adapter/out/redis/devices.go | 2 +- internal/adapter/out/redis/lease_test.go | 2 +- internal/adapter/out/redis/quota.go | 2 +- internal/adapter/out/shortlink/shortlink.go | 4 +- internal/adapter/out/storage/storage.go | 4 +- internal/adapter/out/ytdlp/guard.go | 10 +- internal/adapter/out/ytdlp/guard_test.go | 4 +- internal/adapter/out/ytdlp/hardening_test.go | 8 +- internal/adapter/out/ytdlp/proxy.go | 2 +- internal/adapter/out/ytdlp/ytdlp.go | 28 ++--- internal/adapter/out/ytdlp/ytdlp_test.go | 8 +- internal/app/admin/admin.go | 10 +- internal/app/admin/admin_test.go | 6 +- internal/app/admin/cookies.go | 6 +- internal/app/catalog/catalog.go | 12 +- internal/app/catalog/catalog_test.go | 2 +- internal/app/identity/account.go | 40 +++---- internal/app/identity/account_test.go | 24 ++-- internal/app/identity/github.go | 31 ++--- internal/app/identity/github_test.go | 14 +-- internal/app/identity/identity.go | 12 +- internal/app/identity/identity_test.go | 4 +- internal/app/identity/pairing.go | 10 +- internal/app/identity/pairing_test.go | 4 +- internal/app/identity/password.go | 8 +- internal/app/identity/password_test.go | 2 +- internal/app/identity/secret.go | 4 +- internal/app/lobby/lobby.go | 2 +- internal/app/media/library.go | 4 +- internal/app/media/media.go | 2 +- internal/app/media/playback.go | 12 +- internal/app/media/playback_test.go | 2 +- internal/app/media/ready.go | 10 +- internal/app/playlist/playlist.go | 2 +- internal/app/playlist/playlist_test.go | 2 +- internal/app/playlist/share.go | 4 +- internal/app/port/apikey.go | 2 +- internal/app/port/bilibili.go | 8 +- internal/app/port/bus.go | 16 +-- internal/app/port/catalog.go | 4 +- internal/app/port/cluster.go | 8 +- internal/app/port/github.go | 6 +- internal/app/port/history.go | 2 +- internal/app/port/jobs.go | 6 +- internal/app/port/link.go | 4 +- internal/app/port/listen.go | 2 +- internal/app/port/media.go | 10 +- internal/app/port/member.go | 16 +-- internal/app/port/pairing.go | 6 +- internal/app/port/playlist.go | 15 +-- internal/app/port/porttest/login.go | 2 +- internal/app/port/porttest/redis.go | 2 +- internal/app/port/porttest/snapshot.go | 2 +- internal/app/port/porttest/source.go | 2 +- internal/app/port/porttest/store.go | 4 +- internal/app/port/room.go | 2 +- internal/app/port/shortlink.go | 2 +- internal/app/port/snapshot.go | 2 +- internal/app/port/source.go | 8 +- internal/app/port/ticket.go | 8 +- internal/app/port/tracks.go | 2 +- internal/app/prepare/bilibili_test.go | 2 +- internal/app/prepare/media.go | 18 +-- internal/app/prepare/media_test.go | 2 +- internal/app/prepare/prepare.go | 4 +- internal/app/prepare/prepare_test.go | 2 +- internal/app/realtime/cluster_test.go | 4 +- internal/app/realtime/context_test.go | 2 +- internal/app/realtime/gateway.go | 47 ++++---- internal/app/realtime/gateway_test.go | 8 +- internal/app/realtime/host.go | 50 ++++---- internal/app/realtime/host_test.go | 14 +-- internal/app/realtime/observer.go | 24 ++-- internal/app/realtime/observer_test.go | 6 +- internal/app/realtime/session.go | 10 +- internal/bootstrap/clock.go | 2 +- internal/bootstrap/config.go | 15 +-- internal/bootstrap/contract_test.go | 35 +++--- internal/bootstrap/database.go | 2 +- internal/bootstrap/github_test.go | 30 ++--- internal/bootstrap/httpserver.go | 2 +- internal/bootstrap/logging.go | 6 +- internal/bootstrap/main.go | 2 +- internal/bootstrap/media_test.go | 6 +- internal/bootstrap/metrics_test.go | 2 +- internal/bootstrap/propagation_test.go | 2 +- internal/bootstrap/public.go | 12 +- internal/bootstrap/readiness.go | 4 +- internal/bootstrap/readiness_test.go | 2 +- internal/bootstrap/redis.go | 2 +- internal/bootstrap/serve.go | 16 +-- internal/bootstrap/shutdown_test.go | 16 +-- internal/bootstrap/spans.go | 26 ++-- internal/bootstrap/spans_test.go | 2 +- .../testdata/http/account-quota.txtar | 2 +- .../bootstrap/testdata/http/account.txtar | 2 +- internal/bootstrap/testdata/http/events.txtar | 6 +- .../bootstrap/testdata/http/github-app.txtar | 4 +- .../testdata/http/github-disabled.txtar | 2 +- .../testdata/http/login-device.txtar | 2 +- internal/bootstrap/testdata/http/origin.txtar | 2 +- .../bootstrap/testdata/http/pairing.txtar | 2 +- .../testdata/http/playlist-imports.txtar | 2 +- .../testdata/http/playlist-shares.txtar | 2 +- .../bootstrap/testdata/http/playlists.txtar | 2 +- .../testdata/http/room-requests.txtar | 6 +- .../testdata/http/session-errors.txtar | 2 +- internal/bootstrap/testdata/http/site.txtar | 2 +- .../bootstrap/testdata/http/tickets.txtar | 2 +- .../testdata/ws/account-sessions.txtar | 4 +- internal/bootstrap/testdata/ws/admin.txtar | 2 +- internal/bootstrap/testdata/ws/api-key.txtar | 4 +- internal/bootstrap/testdata/ws/autodj.txtar | 2 +- internal/bootstrap/testdata/ws/bilibili.txtar | 4 +- internal/bootstrap/testdata/ws/catalog.txtar | 4 +- .../bootstrap/testdata/ws/handshake.txtar | 2 +- internal/bootstrap/testdata/ws/listens.txtar | 4 +- internal/bootstrap/testdata/ws/queue.txtar | 2 +- internal/bootstrap/testdata/ws/session.txtar | 4 +- internal/bootstrap/tracing.go | 8 +- internal/bootstrap/tracing_test.go | 2 +- internal/bootstrap/worker.go | 18 +-- internal/bootstrap/worker_test.go | 6 +- internal/domain/playlist/link.go | 2 +- internal/domain/playlist/order.go | 4 +- internal/domain/room/fuzz_test.go | 18 +-- internal/domain/room/limiter.go | 2 +- internal/domain/room/playback.go | 4 +- internal/domain/room/queue.go | 9 +- internal/domain/room/queue_test.go | 6 +- internal/domain/room/room.go | 22 ++-- internal/domain/room/testdata/listen.txtar | 4 +- internal/domain/room/testdata/presence.txtar | 2 +- internal/domain/room/testdata/queue.txtar | 2 +- internal/domain/room/testdata/rotation.txtar | 2 +- .../domain/room/testdata/sequential.txtar | 2 +- internal/domain/track/bilibili.go | 2 +- internal/domain/track/track.go | 8 +- internal/lib/logctx/logctx.go | 4 +- internal/lib/logctx/logctx_test.go | 2 +- internal/lib/loudness/loudness.go | 14 +-- internal/lib/loudness/loudness_amd64.go | 4 +- internal/lib/loudness/loudness_amd64.s | 6 +- internal/lib/loudness/loudness_test.go | 18 +-- internal/lib/loudness/mp3_test.go | 10 +- internal/lib/wav/decode.go | 8 +- internal/lib/wav/decode_test.go | 8 +- internal/lib/wav/mp3.go | 8 +- protocol/decode.go | 12 +- protocol/http.go | 4 +- protocol/idl.json | 112 +++++++++--------- protocol/openapi.gen.json | 112 +++++++++--------- protocol/types.gen.go | 2 +- protocol/ws.go | 4 +- web/eslint.config.mjs | 4 +- .../templates/alisten.d/proxy.conf.template | 14 +-- web/src/app/app.tsx | 10 +- web/src/app/connection-notices.ts | 2 +- web/src/app/detail-panel.tsx | 2 +- web/src/app/error-boundary.tsx | 6 +- web/src/app/main.tsx | 4 +- web/src/app/navigation.ts | 14 +-- web/src/app/route.ts | 20 ++-- web/src/app/style.css | 8 +- web/src/app/view-transition.ts | 2 +- web/src/core/api/client.ts | 14 +-- web/src/core/api/credentials.ts | 2 +- web/src/core/brand/brand-store.ts | 4 +- web/src/core/errors/error-message.ts | 4 +- web/src/core/realtime/connection.test.ts | 2 +- web/src/core/realtime/connection.ts | 4 +- web/src/core/realtime/protocol.gen.ts | 2 +- web/src/core/room/replica.ts | 2 +- web/src/features/admin/accounts.tsx | 2 +- web/src/features/admin/admin-page.tsx | 2 +- web/src/features/admin/admin-store.ts | 4 +- web/src/features/admin/admin.css | 12 +- web/src/features/admin/github-app.tsx | 2 +- web/src/features/admin/media-store.test.ts | 2 +- web/src/features/admin/media-store.ts | 8 +- web/src/features/admin/media-view.ts | 4 +- web/src/features/admin/persistent-rooms.tsx | 6 +- web/src/features/admin/qr-login.ts | 2 +- web/src/features/admin/registration.tsx | 2 +- web/src/features/admin/removal-focus.ts | 2 +- .../features/admin/room-api-key-dialog.tsx | 6 +- web/src/features/admin/section-head.tsx | 2 +- web/src/features/admin/site-settings.tsx | 2 +- .../admin/source-account-view.test.ts | 2 +- web/src/features/admin/stored-media.tsx | 6 +- web/src/features/admin/youtube-cookies.tsx | 4 +- web/src/features/chat/chat-panel.tsx | 2 +- web/src/features/chat/chat-store.ts | 2 +- web/src/features/history/history-store.ts | 16 +-- web/src/features/lyrics/lrc.ts | 10 +- web/src/features/lyrics/lyrics-store.ts | 2 +- web/src/features/lyrics/lyrics.css | 2 +- web/src/features/lyrics/lyrics.tsx | 6 +- web/src/features/playback/playback.css | 2 +- web/src/features/playback/player-bar.tsx | 2 +- web/src/features/playback/player-store.ts | 8 +- web/src/features/playback/reminder-store.ts | 4 +- .../features/playlists/account-settings.ts | 2 +- .../playlists/create-playlist-form.tsx | 4 +- .../features/playlists/my-playlist-page.tsx | 16 +-- .../features/playlists/playlist-date.test.ts | 2 +- web/src/features/playlists/playlist-file.ts | 2 +- web/src/features/playlists/playlist-menu.tsx | 2 +- web/src/features/playlists/playlist-sort.ts | 2 +- .../features/playlists/playlist-tracks.tsx | 4 +- web/src/features/playlists/playlists-page.tsx | 2 +- .../playlists/playlists-store.test.ts | 2 +- web/src/features/playlists/playlists-store.ts | 22 ++-- web/src/features/playlists/playlists.css | 2 +- .../features/playlists/save-to-playlist.tsx | 6 +- .../playlists/shared-playlist-page.tsx | 2 +- web/src/features/playlists/track-edits.ts | 6 +- web/src/features/playlists/track-order.ts | 6 +- web/src/features/playlists/track-selection.ts | 4 +- web/src/features/playlists/use-track-drag.ts | 2 +- web/src/features/rooms/create-room-dialog.tsx | 2 +- web/src/features/rooms/entry-dialog.tsx | 2 +- web/src/features/rooms/room-entry.ts | 2 +- web/src/features/search/link.ts | 2 +- web/src/features/search/search-box.tsx | 4 +- web/src/features/search/search-store.ts | 14 +-- web/src/features/search/search.css | 2 +- web/src/features/settings/account-dialogs.tsx | 4 +- web/src/features/settings/appearance-store.ts | 2 +- .../features/settings/github-login-page.tsx | 2 +- web/src/features/settings/github.ts | 12 +- .../settings/notification-permission.ts | 8 +- web/src/features/settings/pair-page.tsx | 2 +- web/src/features/settings/pairing-dialog.tsx | 2 +- web/src/features/settings/password-login.ts | 6 +- web/src/features/settings/profile-store.ts | 12 +- web/src/features/settings/setting-row.tsx | 2 +- .../features/settings/settings-account.tsx | 8 +- web/src/features/settings/settings-labels.ts | 2 +- .../features/settings/settings-playback.tsx | 2 +- web/src/features/settings/settings.css | 8 +- web/src/features/settings/use-site.ts | 1 - web/src/shared/lib/book-title.ts | 2 +- web/src/shared/lib/ime.ts | 4 +- web/src/shared/lib/inline-save.ts | 4 +- web/src/shared/lib/page-items.ts | 4 +- web/src/shared/lib/range-selection.ts | 2 +- web/src/shared/lib/removal-focus.ts | 4 +- web/src/shared/lib/roving.ts | 2 +- web/src/shared/lib/session-history.test.ts | 2 +- web/src/shared/lib/session-history.ts | 18 +-- web/src/shared/lib/setting-save.ts | 4 +- web/src/shared/lib/store.ts | 2 +- web/src/shared/lib/use-inline-save.ts | 6 +- web/src/shared/lib/use-setting-save.ts | 2 +- web/src/shared/lib/use-store.ts | 2 +- web/src/shared/ui/action-menu.tsx | 2 +- web/src/shared/ui/avatar.tsx | 8 +- web/src/shared/ui/lazy-page.tsx | 12 +- web/src/shared/ui/modal.tsx | 2 +- web/src/shared/ui/pagination.tsx | 2 +- web/src/shared/ui/section-layout.css | 4 +- web/src/shared/ui/section-layout.tsx | 14 +-- web/src/shared/ui/switch.tsx | 2 +- web/src/shared/ui/ui.css | 4 +- web/vite.config.ts | 6 +- 324 files changed, 1134 insertions(+), 1125 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 733275c..a4cf5b8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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: diff --git a/cmd/protocolgen/idl.go b/cmd/protocolgen/idl.go index d2a6758..4246cba 100644 --- a/cmd/protocolgen/idl.go +++ b/cmd/protocolgen/idl.go @@ -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 { diff --git a/cmd/protocolgen/main.go b/cmd/protocolgen/main.go index c0a725d..b657722 100644 --- a/cmd/protocolgen/main.go +++ b/cmd/protocolgen/main.go @@ -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 { diff --git a/cmd/protocolgen/openapi.go b/cmd/protocolgen/openapi.go index cad280c..c944725 100644 --- a/cmd/protocolgen/openapi.go +++ b/cmd/protocolgen/openapi.go @@ -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 diff --git a/db/migrations/00001_schema.sql b/db/migrations/00001_schema.sql index 3f3f4a7..9709c99 100644 --- a/db/migrations/00001_schema.sql +++ b/db/migrations/00001_schema.sql @@ -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 diff --git a/db/migrations/00003_member_accounts.sql b/db/migrations/00003_member_accounts.sql index 867770b..0f0c6b5 100644 --- a/db/migrations/00003_member_accounts.sql +++ b/db/migrations/00003_member_accounts.sql @@ -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; diff --git a/db/migrations/00004_playlists.sql b/db/migrations/00004_playlists.sql index a4d5692..921cf91 100644 --- a/db/migrations/00004_playlists.sql +++ b/db/migrations/00004_playlists.sql @@ -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, diff --git a/db/migrations/00005_listens.sql b/db/migrations/00005_listens.sql index f3eb6c0..538f474 100644 --- a/db/migrations/00005_listens.sql +++ b/db/migrations/00005_listens.sql @@ -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, diff --git a/db/migrations/00006_playlist_shares.sql b/db/migrations/00006_playlist_shares.sql index 866e1a1..bbae955 100644 --- a/db/migrations/00006_playlist_shares.sql +++ b/db/migrations/00006_playlist_shares.sql @@ -1,3 +1,3 @@ -- +goose Up --- 分享链接中的随机串,NULL 表示没有分享。明文保存:所有者随时可以再复制一次链接。 +-- 分享链接中的随机串,NULL 表示没有分享。明文保存,这样所有者随时可以再复制一次链接。 ALTER TABLE playlists ADD COLUMN share_id text UNIQUE; diff --git a/db/migrations/00007_member_github.sql b/db/migrations/00007_member_github.sql index c0090db..b7c8328 100644 --- a/db/migrations/00007_member_github.sql +++ b/db/migrations/00007_member_github.sql @@ -1,5 +1,5 @@ -- +goose Up --- 绑定了 GitHub 的成员也是已注册的账号,可以没有用户名和密码;账号的邮箱因此也可以属于只绑定了 GitHub 的成员。 +-- 绑定了 GitHub 的成员也是已注册的账号,可以没有用户名和密码。所以账号的邮箱也可以属于只绑定了 GitHub 的成员。 ALTER TABLE members ADD COLUMN github_id bigint UNIQUE, DROP CONSTRAINT members_email_check, diff --git a/db/queries/apikeys.sql b/db/queries/apikeys.sql index 2c0e042..aa04eaa 100644 --- a/db/queries/apikeys.sql +++ b/db/queries/apikeys.sql @@ -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 diff --git a/db/queries/history.sql b/db/queries/history.sql index 227f968..61d8b22 100644 --- a/db/queries/history.sql +++ b/db/queries/history.sql @@ -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 ( diff --git a/db/queries/listens.sql b/db/queries/listens.sql index 789f779..3fce62d 100644 --- a/db/queries/listens.sql +++ b/db/queries/listens.sql @@ -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, diff --git a/db/queries/members.sql b/db/queries/members.sql index 8b7e8a5..750cfad 100644 --- a/db/queries/members.sql +++ b/db/queries/members.sql @@ -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 diff --git a/db/queries/playlists.sql b/db/queries/playlists.sql index bd6f103..0649d4c 100644 --- a/db/queries/playlists.sql +++ b/db/queries/playlists.sql @@ -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 diff --git a/db/queries/tickets.sql b/db/queries/tickets.sql index 83df7be..599f595 100644 --- a/db/queries/tickets.sql +++ b/db/queries/tickets.sql @@ -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 diff --git a/db/queries/tracks.sql b/db/queries/tracks.sql index 0655c68..ca25ebc 100644 --- a/db/queries/tracks.sql +++ b/db/queries/tracks.sql @@ -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 diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index 1db4240..aadbff5 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -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) { diff --git a/internal/adapter/in/api/api_test.go b/internal/adapter/in/api/api_test.go index 954ade6..ea83966 100644 --- a/internal/adapter/in/api/api_test.go +++ b/internal/adapter/in/api/api_test.go @@ -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") diff --git a/internal/adapter/in/api/clientip.go b/internal/adapter/in/api/clientip.go index 42a36a5..ade9d0b 100644 --- a/internal/adapter/in/api/clientip.go +++ b/internal/adapter/in/api/clientip.go @@ -7,8 +7,8 @@ import ( ) // 只有对端是回环、私有网段或链路本地地址的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理, -// 第一个不受信任的地址就是客户端;全部受信任时取最左端的地址。受信任的代理只会写入合法的地址, -// 遇到不合法的值说明已经到了客户端自己填写的部分,此时改用对端地址。 +// 第一个不受信任的地址就是客户端。如果全都受信任,就取最左端的地址。受信任的代理只会写入合法的地址, +// 所以遇到不合法的值,说明已经到了客户端自己填写的部分,这时改用对端地址。 func clientIP(r *http.Request) string { peer, err := netip.ParseAddrPort(r.RemoteAddr) if err != nil { diff --git a/internal/adapter/in/api/convert.go b/internal/adapter/in/api/convert.go index e77a336..9d9524c 100644 --- a/internal/adapter/in/api/convert.go +++ b/internal/adapter/in/api/convert.go @@ -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 diff --git a/internal/adapter/in/api/events.go b/internal/adapter/in/api/events.go index 246f2c5..7c836df 100644 --- a/internal/adapter/in/api/events.go +++ b/internal/adapter/in/api/events.go @@ -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 { diff --git a/internal/adapter/in/api/events_test.go b/internal/adapter/in/api/events_test.go index 80780e3..8db850b 100644 --- a/internal/adapter/in/api/events_test.go +++ b/internal/adapter/in/api/events_test.go @@ -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) } diff --git a/internal/adapter/in/api/github.go b/internal/adapter/in/api/github.go index aef5654..90db45a 100644 --- a/internal/adapter/in/api/github.go +++ b/internal/adapter/in/api/github.go @@ -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) } diff --git a/internal/adapter/in/api/media.go b/internal/adapter/in/api/media.go index 795dc10..a62fcc0 100644 --- a/internal/adapter/in/api/media.go +++ b/internal/adapter/in/api/media.go @@ -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) } diff --git a/internal/adapter/in/api/ws.go b/internal/adapter/in/api/ws.go index f71d240..01ae539 100644 --- a/internal/adapter/in/api/ws.go +++ b/internal/adapter/in/api/ws.go @@ -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 } diff --git a/internal/adapter/in/api/ws_test.go b/internal/adapter/in/api/ws_test.go index 702ad40..a148e1e 100644 --- a/internal/adapter/in/api/ws_test.go +++ b/internal/adapter/in/api/ws_test.go @@ -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 { diff --git a/internal/adapter/in/worker/worker.go b/internal/adapter/in/worker/worker.go index 4e5847f..bf59baf 100644 --- a/internal/adapter/in/worker/worker.go +++ b/internal/adapter/in/worker/worker.go @@ -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 "准备任务失败" diff --git a/internal/adapter/out/ffmpeg/ffmpeg.go b/internal/adapter/out/ffmpeg/ffmpeg.go index ac94c79..c23f205 100644 --- a/internal/adapter/out/ffmpeg/ffmpeg.go +++ b/internal/adapter/out/ffmpeg/ffmpeg.go @@ -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, } diff --git a/internal/adapter/out/ffmpeg/ffmpeg_test.go b/internal/adapter/out/ffmpeg/ffmpeg_test.go index b4ca346..55020bf 100644 --- a/internal/adapter/out/ffmpeg/ffmpeg_test.go +++ b/internal/adapter/out/ffmpeg/ffmpeg_test.go @@ -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))) diff --git a/internal/adapter/out/postgres/apikeys_test.go b/internal/adapter/out/postgres/apikeys_test.go index 7eadcaf..aa16b2d 100644 --- a/internal/adapter/out/postgres/apikeys_test.go +++ b/internal/adapter/out/postgres/apikeys_test.go @@ -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) diff --git a/internal/adapter/out/postgres/github_app.go b/internal/adapter/out/postgres/github_app.go index eff7218..8b2fec7 100644 --- a/internal/adapter/out/postgres/github_app.go +++ b/internal/adapter/out/postgres/github_app.go @@ -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 的格式不正确") } diff --git a/internal/adapter/out/postgres/github_app_test.go b/internal/adapter/out/postgres/github_app_test.go index a162fef..0d62b6d 100644 --- a/internal/adapter/out/postgres/github_app_test.go +++ b/internal/adapter/out/postgres/github_app_test.go @@ -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"}`} { diff --git a/internal/adapter/out/postgres/internal/query/apikeys.sql.go b/internal/adapter/out/postgres/internal/query/apikeys.sql.go index badecf9..156e7fa 100644 --- a/internal/adapter/out/postgres/internal/query/apikeys.sql.go +++ b/internal/adapter/out/postgres/internal/query/apikeys.sql.go @@ -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 diff --git a/internal/adapter/out/postgres/internal/query/history.sql.go b/internal/adapter/out/postgres/internal/query/history.sql.go index 46ac7ee..074986f 100644 --- a/internal/adapter/out/postgres/internal/query/history.sql.go +++ b/internal/adapter/out/postgres/internal/query/history.sql.go @@ -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 { diff --git a/internal/adapter/out/postgres/internal/query/listens.sql.go b/internal/adapter/out/postgres/internal/query/listens.sql.go index cdd460c..9efd37c 100644 --- a/internal/adapter/out/postgres/internal/query/listens.sql.go +++ b/internal/adapter/out/postgres/internal/query/listens.sql.go @@ -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 diff --git a/internal/adapter/out/postgres/internal/query/members.sql.go b/internal/adapter/out/postgres/internal/query/members.sql.go index 799a086..2bba5d7 100644 --- a/internal/adapter/out/postgres/internal/query/members.sql.go +++ b/internal/adapter/out/postgres/internal/query/members.sql.go @@ -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, diff --git a/internal/adapter/out/postgres/internal/query/playlists.sql.go b/internal/adapter/out/postgres/internal/query/playlists.sql.go index 3e7fcb2..3c75dfb 100644 --- a/internal/adapter/out/postgres/internal/query/playlists.sql.go +++ b/internal/adapter/out/postgres/internal/query/playlists.sql.go @@ -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 diff --git a/internal/adapter/out/postgres/internal/query/tickets.sql.go b/internal/adapter/out/postgres/internal/query/tickets.sql.go index a4892e3..7f3cbdb 100644 --- a/internal/adapter/out/postgres/internal/query/tickets.sql.go +++ b/internal/adapter/out/postgres/internal/query/tickets.sql.go @@ -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 diff --git a/internal/adapter/out/postgres/internal/query/tracks.sql.go b/internal/adapter/out/postgres/internal/query/tracks.sql.go index bd419c9..cd91728 100644 --- a/internal/adapter/out/postgres/internal/query/tracks.sql.go +++ b/internal/adapter/out/postgres/internal/query/tracks.sql.go @@ -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, diff --git a/internal/adapter/out/postgres/listens.go b/internal/adapter/out/postgres/listens.go index 2375c66..f5466f0 100644 --- a/internal/adapter/out/postgres/listens.go +++ b/internal/adapter/out/postgres/listens.go @@ -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 diff --git a/internal/adapter/out/postgres/listens_test.go b/internal/adapter/out/postgres/listens_test.go index 0129387..610d346 100644 --- a/internal/adapter/out/postgres/listens_test.go +++ b/internal/adapter/out/postgres/listens_test.go @@ -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 diff --git a/internal/adapter/out/postgres/media.go b/internal/adapter/out/postgres/media.go index bacb39f..c37a6bf 100644 --- a/internal/adapter/out/postgres/media.go +++ b/internal/adapter/out/postgres/media.go @@ -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 } diff --git a/internal/adapter/out/postgres/media_test.go b/internal/adapter/out/postgres/media_test.go index 6d10479..12cc548 100644 --- a/internal/adapter/out/postgres/media_test.go +++ b/internal/adapter/out/postgres/media_test.go @@ -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 diff --git a/internal/adapter/out/postgres/members.go b/internal/adapter/out/postgres/members.go index 9b80204..87f19db 100644 --- a/internal/adapter/out/postgres/members.go +++ b/internal/adapter/out/postgres/members.go @@ -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 diff --git a/internal/adapter/out/postgres/members_test.go b/internal/adapter/out/postgres/members_test.go index 8906a1f..6e056b3 100644 --- a/internal/adapter/out/postgres/members_test.go +++ b/internal/adapter/out/postgres/members_test.go @@ -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") diff --git a/internal/adapter/out/postgres/migrate.go b/internal/adapter/out/postgres/migrate.go index c2f1d87..3fd88c9 100644 --- a/internal/adapter/out/postgres/migrate.go +++ b/internal/adapter/out/postgres/migrate.go @@ -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) diff --git a/internal/adapter/out/postgres/migrate_test.go b/internal/adapter/out/postgres/migrate_test.go index 3b5842e..2aa1ffe 100644 --- a/internal/adapter/out/postgres/migrate_test.go +++ b/internal/adapter/out/postgres/migrate_test.go @@ -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) } diff --git a/internal/adapter/out/postgres/playlists.go b/internal/adapter/out/postgres/playlists.go index c5d2dbe..1b68703 100644 --- a/internal/adapter/out/postgres/playlists.go +++ b/internal/adapter/out/postgres/playlists.go @@ -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) diff --git a/internal/adapter/out/postgres/ready.go b/internal/adapter/out/postgres/ready.go index 2b3dbd3..63198ef 100644 --- a/internal/adapter/out/postgres/ready.go +++ b/internal/adapter/out/postgres/ready.go @@ -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() }() diff --git a/internal/adapter/out/postgres/snapshot.go b/internal/adapter/out/postgres/snapshot.go index b2e70e5..254e14f 100644 --- a/internal/adapter/out/postgres/snapshot.go +++ b/internal/adapter/out/postgres/snapshot.go @@ -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)) diff --git a/internal/adapter/out/postgres/snapshot_test.go b/internal/adapter/out/postgres/snapshot_test.go index 41bb7c8..e82874a 100644 --- a/internal/adapter/out/postgres/snapshot_test.go +++ b/internal/adapter/out/postgres/snapshot_test.go @@ -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}, diff --git a/internal/adapter/out/postgres/store.go b/internal/adapter/out/postgres/store.go index c1d0d5d..1bd96f7 100644 --- a/internal/adapter/out/postgres/store.go +++ b/internal/adapter/out/postgres/store.go @@ -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) } diff --git a/internal/adapter/out/queue/queue.go b/internal/adapter/out/queue/queue.go index 4c4ad27..65ad70e 100644 --- a/internal/adapter/out/queue/queue.go +++ b/internal/adapter/out/queue/queue.go @@ -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 "准备任务失败" diff --git a/internal/adapter/out/redis/bus.go b/internal/adapter/out/redis/bus.go index 2dfeba1..67f49d2 100644 --- a/internal/adapter/out/redis/bus.go +++ b/internal/adapter/out/redis/bus.go @@ -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)) diff --git a/internal/adapter/out/redis/bus_test.go b/internal/adapter/out/redis/bus_test.go index 88945b2..c765c3a 100644 --- a/internal/adapter/out/redis/bus_test.go +++ b/internal/adapter/out/redis/bus_test.go @@ -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) diff --git a/internal/adapter/out/redis/codec.go b/internal/adapter/out/redis/codec.go index bdc42d1..085a310 100644 --- a/internal/adapter/out/redis/codec.go +++ b/internal/adapter/out/redis/codec.go @@ -65,7 +65,7 @@ const ( inputCommand = "Command" ) -// 接口实现必须登记后才能编码;实现字段中不能再有接口类型。 +// 接口的实现必须先登记才能编码。实现的字段里不能再有接口类型。 type union[T any] map[string]reflect.Type func newUnion[T any](variants ...T) union[T] { diff --git a/internal/adapter/out/redis/codes.go b/internal/adapter/out/redis/codes.go index b975f67..2f944e9 100644 --- a/internal/adapter/out/redis/codes.go +++ b/internal/adapter/out/redis/codes.go @@ -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) { diff --git a/internal/adapter/out/redis/devices.go b/internal/adapter/out/redis/devices.go index 7a27735..162f666 100644 --- a/internal/adapter/out/redis/devices.go +++ b/internal/adapter/out/redis/devices.go @@ -16,7 +16,7 @@ var _ port.LoginDevices = (*Devices)(nil) const devicePrefix = "device:" -// 登录设备凭证,键中只有它的哈希,值是所属成员的 ID 和签发时的密码指纹。 +// 存放登录设备凭证。键里只有凭证的哈希,值是所属成员的 ID 和签发时的密码指纹。 type Devices struct { client *goredis.Client } diff --git a/internal/adapter/out/redis/lease_test.go b/internal/adapter/out/redis/lease_test.go index 4443776..df127f1 100644 --- a/internal/adapter/out/redis/lease_test.go +++ b/internal/adapter/out/redis/lease_test.go @@ -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 { diff --git a/internal/adapter/out/redis/quota.go b/internal/adapter/out/redis/quota.go index 8c38718..f24bcfa 100644 --- a/internal/adapter/out/redis/quota.go +++ b/internal/adapter/out/redis/quota.go @@ -1,4 +1,4 @@ -// Package redis 只存放可以丢失的协调数据:Redis 被清空后系统能自行恢复。 +// Package redis 只存放可以丢失的协调数据,Redis 被清空后系统能自行恢复。 package redis import ( diff --git a/internal/adapter/out/shortlink/shortlink.go b/internal/adapter/out/shortlink/shortlink.go index fb690ed..5d55b7a 100644 --- a/internal/adapter/out/shortlink/shortlink.go +++ b/internal/adapter/out/shortlink/shortlink.go @@ -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, diff --git a/internal/adapter/out/storage/storage.go b/internal/adapter/out/storage/storage.go index 942d4a9..fc00ac1 100644 --- a/internal/adapter/out/storage/storage.go +++ b/internal/adapter/out/storage/storage.go @@ -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)}) diff --git a/internal/adapter/out/ytdlp/guard.go b/internal/adapter/out/ytdlp/guard.go index f0baef3..8f744c1 100644 --- a/internal/adapter/out/ytdlp/guard.go +++ b/internal/adapter/out/ytdlp/guard.go @@ -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) diff --git a/internal/adapter/out/ytdlp/guard_test.go b/internal/adapter/out/ytdlp/guard_test.go index 3e9617b..7247708 100644 --- a/internal/adapter/out/ytdlp/guard_test.go +++ b/internal/adapter/out/ytdlp/guard_test.go @@ -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 diff --git a/internal/adapter/out/ytdlp/hardening_test.go b/internal/adapter/out/ytdlp/hardening_test.go index ec8dd3b..46a6d13 100644 --- a/internal/adapter/out/ytdlp/hardening_test.go +++ b/internal/adapter/out/ytdlp/hardening_test.go @@ -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) } diff --git a/internal/adapter/out/ytdlp/proxy.go b/internal/adapter/out/ytdlp/proxy.go index c275fdf..7eab94a 100644 --- a/internal/adapter/out/ytdlp/proxy.go +++ b/internal/adapter/out/ytdlp/proxy.go @@ -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 } diff --git a/internal/adapter/out/ytdlp/ytdlp.go b/internal/adapter/out/ytdlp/ytdlp.go index cd6d838..4bc9481 100644 --- a/internal/adapter/out/ytdlp/ytdlp.go +++ b/internal/adapter/out/ytdlp/ytdlp.go @@ -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 diff --git a/internal/adapter/out/ytdlp/ytdlp_test.go b/internal/adapter/out/ytdlp/ytdlp_test.go index e217601..2265916 100644 --- a/internal/adapter/out/ytdlp/ytdlp_test.go +++ b/internal/adapter/out/ytdlp/ytdlp_test.go @@ -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" + diff --git a/internal/app/admin/admin.go b/internal/app/admin/admin.go index adcde57..37de6c5 100644 --- a/internal/app/admin/admin.go +++ b/internal/app/admin/admin.go @@ -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) diff --git a/internal/app/admin/admin_test.go b/internal/app/admin/admin_test.go index ff90c30..b931c9d 100644 --- a/internal/app/admin/admin_test.go +++ b/internal/app/admin/admin_test.go @@ -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: "大厅"}) diff --git a/internal/app/admin/cookies.go b/internal/app/admin/cookies.go index 61393d1..16bf02e 100644 --- a/internal/app/admin/cookies.go +++ b/internal/app/admin/cookies.go @@ -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") { diff --git a/internal/app/catalog/catalog.go b/internal/app/catalog/catalog.go index eaa985b..a133571 100644 --- a/internal/app/catalog/catalog.go +++ b/internal/app/catalog/catalog.go @@ -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 { diff --git a/internal/app/catalog/catalog_test.go b/internal/app/catalog/catalog_test.go index c1f8149..c2d2209 100644 --- a/internal/app/catalog/catalog_test.go +++ b/internal/app/catalog/catalog_test.go @@ -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) { diff --git a/internal/app/identity/account.go b/internal/app/identity/account.go index 8f3a1ce..865b30b 100644 --- a/internal/app/identity/account.go +++ b/internal/app/identity/account.go @@ -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 { diff --git a/internal/app/identity/account_test.go b/internal/app/identity/account_test.go index 6db1351..e1fbb99 100644 --- a/internal/app/identity/account_test.go +++ b/internal/app/identity/account_test.go @@ -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) diff --git a/internal/app/identity/github.go b/internal/app/identity/github.go index 4cc08f5..162a677 100644 --- a/internal/app/identity/github.go +++ b/internal/app/identity/github.go @@ -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 { diff --git a/internal/app/identity/github_test.go b/internal/app/identity/github_test.go index e8f8cb6..a19bd49 100644 --- a/internal/app/identity/github_test.go +++ b/internal/app/identity/github_test.go @@ -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") diff --git a/internal/app/identity/identity.go b/internal/app/identity/identity.go index fc37ce0..a90700a 100644 --- a/internal/app/identity/identity.go +++ b/internal/app/identity/identity.go @@ -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 diff --git a/internal/app/identity/identity_test.go b/internal/app/identity/identity_test.go index 914dd84..8c262e6 100644 --- a/internal/app/identity/identity_test.go +++ b/internal/app/identity/identity_test.go @@ -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 { diff --git a/internal/app/identity/pairing.go b/internal/app/identity/pairing.go index ccda7ef..f862fda 100644 --- a/internal/app/identity/pairing.go +++ b/internal/app/identity/pairing.go @@ -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 { diff --git a/internal/app/identity/pairing_test.go b/internal/app/identity/pairing_test.go index f2a8934..e817e70 100644 --- a/internal/app/identity/pairing_test.go +++ b/internal/app/identity/pairing_test.go @@ -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") diff --git a/internal/app/identity/password.go b/internal/app/identity/password.go index 6a67043..c9c8a2e 100644 --- a/internal/app/identity/password.go +++ b/internal/app/identity/password.go @@ -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 { diff --git a/internal/app/identity/password_test.go b/internal/app/identity/password_test.go index 32f1331..f0ec0e5 100644 --- a/internal/app/identity/password_test.go +++ b/internal/app/identity/password_test.go @@ -51,7 +51,7 @@ func occupyArgonSlots(t *testing.T) { for range cap(argonSlots) { argonSlots <- struct{}{} } - // 归还名额时不阻塞:实现在 ctx 结束之后仍去计算时,会在结束时替这里归还一个名额;阻塞地归还会让测试一直挂到包超时, + // 归还名额时不阻塞。如果实现在 ctx 结束之后仍去计算,计算结束时会替测试归还一个名额,这时阻塞地归还会让测试一直挂到包超时, // 而不是报出错误。 t.Cleanup(func() { for range cap(argonSlots) { diff --git a/internal/app/identity/secret.go b/internal/app/identity/secret.go index 3cbead9..33cc0b6 100644 --- a/internal/app/identity/secret.go +++ b/internal/app/identity/secret.go @@ -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) diff --git a/internal/app/lobby/lobby.go b/internal/app/lobby/lobby.go index bf2ac22..f224472 100644 --- a/internal/app/lobby/lobby.go +++ b/internal/app/lobby/lobby.go @@ -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 { diff --git a/internal/app/media/library.go b/internal/app/media/library.go index 15b7906..00b250e 100644 --- a/internal/app/media/library.go +++ b/internal/app/media/library.go @@ -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) diff --git a/internal/app/media/media.go b/internal/app/media/media.go index f4786eb..7e553e1 100644 --- a/internal/app/media/media.go +++ b/internal/app/media/media.go @@ -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 } diff --git a/internal/app/media/playback.go b/internal/app/media/playback.go index 8904a33..ab99b9f 100644 --- a/internal/app/media/playback.go +++ b/internal/app/media/playback.go @@ -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() == "" { diff --git a/internal/app/media/playback_test.go b/internal/app/media/playback_test.go index 912fc47..2405183 100644 --- a/internal/app/media/playback_test.go +++ b/internal/app/media/playback_test.go @@ -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 } diff --git a/internal/app/media/ready.go b/internal/app/media/ready.go index 26e3d19..1db6be9 100644 --- a/internal/app/media/ready.go +++ b/internal/app/media/ready.go @@ -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 { diff --git a/internal/app/playlist/playlist.go b/internal/app/playlist/playlist.go index 0ec8590..78efbb8 100644 --- a/internal/app/playlist/playlist.go +++ b/internal/app/playlist/playlist.go @@ -17,7 +17,7 @@ const ( maxPlaylists = 50 maxItems = 1000 maxNameLength = 50 - // 收藏可能要向音源查询元数据:额度防止批量请求让共用的音源账号被平台限流。 + // 收藏可能要向音源查询元数据。这份额度防止批量请求让共用的音源账号被平台限流。 addBurst = 30 addRefill = 2 * time.Second ) diff --git a/internal/app/playlist/playlist_test.go b/internal/app/playlist/playlist_test.go index 48ad44d..9be52fe 100644 --- a/internal/app/playlist/playlist_test.go +++ b/internal/app/playlist/playlist_test.go @@ -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) { diff --git a/internal/app/playlist/share.go b/internal/app/playlist/share.go index e097cde..1a3d2e9 100644 --- a/internal/app/playlist/share.go +++ b/internal/app/playlist/share.go @@ -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 diff --git a/internal/app/port/apikey.go b/internal/app/port/apikey.go index da8f825..d4c426b 100644 --- a/internal/app/port/apikey.go +++ b/internal/app/port/apikey.go @@ -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 立即失效。 diff --git a/internal/app/port/bilibili.go b/internal/app/port/bilibili.go index 8b4c20a..f784e12 100644 --- a/internal/app/port/bilibili.go +++ b/internal/app/port/bilibili.go @@ -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) } diff --git a/internal/app/port/bus.go b/internal/app/port/bus.go index 9cd4e14..e8fa183 100644 --- a/internal/app/port/bus.go +++ b/internal/app/port/bus.go @@ -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) } diff --git a/internal/app/port/catalog.go b/internal/app/port/catalog.go index 570255c..dd16188 100644 --- a/internal/app/port/catalog.go +++ b/internal/app/port/catalog.go @@ -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) } diff --git a/internal/app/port/cluster.go b/internal/app/port/cluster.go index 38dcf74..f13523e 100644 --- a/internal/app/port/cluster.go +++ b/internal/app/port/cluster.go @@ -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) } diff --git a/internal/app/port/github.go b/internal/app/port/github.go index 31a9065..036e16a 100644 --- a/internal/app/port/github.go +++ b/internal/app/port/github.go @@ -8,7 +8,7 @@ import ( type GitHubIntent int const ( - // 游客用 GitHub 登录:GitHub 账号没有绑定过时,就是注册。 + // 游客用 GitHub 登录。GitHub 账号还没有绑定过时,登录就是注册。 GitHubLogin GitHubIntent = iota + 1 // 已注册的成员绑定 GitHub。 GitHubBind @@ -33,7 +33,7 @@ type GitHubRequest struct { MemberID string // 发起授权的客户端保存的 nonce 的 SHA-256。 NonceHash []byte - // PKCE 的 code verifier:GitHub 签发的授权码只能配合它换取令牌,别人的授权码放进这次授权的回调里换不到令牌。 + // PKCE 的 code verifier。GitHub 签发的授权码只能配合 verifier 换取令牌,所以别人的授权码放进这次授权的回调里也换不到令牌。 CodeVerifier string } @@ -60,7 +60,7 @@ type GitHubOAuthApps interface { // GitHub 的 OAuth 授权,使用调用方传入的 OAuth App。 type GitHubOAuth interface { - // 请用户授权的地址:用户同意之后,GitHub 带着 state 和授权码回调,授权码与 verifier 绑定。 + // 请用户授权的地址。用户同意之后,GitHub 带着 state 和授权码回调,授权码与 verifier 绑定。 AuthorizeURL(app GitHubOAuthApp, state, verifier string) string // 用回调带回的授权码和发起那次授权时的 verifier 换取 GitHub 用户的 ID。 UserID(ctx context.Context, app GitHubOAuthApp, code, verifier string) (int64, error) diff --git a/internal/app/port/history.go b/internal/app/port/history.go index af2804c..9ec3ec0 100644 --- a/internal/app/port/history.go +++ b/internal/app/port/history.go @@ -7,7 +7,7 @@ import ( ) type PlayHistory interface { - // 本房间最近 32 首不同的、由成员点播的网易云歌曲:先是正在播放的条目,includeQueue 时接着是待播和 + // 本房间最近 32 首成员点播的网易云歌曲,不重复。先是正在播放的条目,includeQueue 为真时接着是待播和 // 等待队列中的条目,然后是按开播时间从近到远的播放记录。 AutoDJSeeds(ctx context.Context, roomID string, includeQueue bool) ([]track.Track, error) // 本房间最近 128 首由 AutoDJ 加入的歌(包括正在播放的条目),加上待播和等待队列中的全部条目。 diff --git a/internal/app/port/jobs.go b/internal/app/port/jobs.go index 677a25e..acc985f 100644 --- a/internal/app/port/jobs.go +++ b/internal/app/port/jobs.go @@ -11,14 +11,14 @@ type JobState uint8 const ( // 等待执行、正在执行或者等待重试。 JobPending JobState = iota + 1 - // 已完成或者已被清理,不会再执行;成功的结果已经写入曲目表。 + // 已完成或者已被清理,不会再执行。成功的结果已经写入曲目表。 JobFinished // 被取消或者重试之后仍然失败,不会再执行。 JobFailed ) -// 任务重试用完时告诉用户的原因。原因会出现在房间动态里,也会被 bot 转发;最后一次的错误可能是下载工具的输出或 IO 错误, -// 只写进日志。音源拒绝时的原因本来就是写给用户的,照旧使用。 +// 任务重试用完时告诉用户的原因。这个原因会出现在房间动态里,也会被 bot 转发。最后一次的错误可能是下载工具的输出或 IO 错误, +// 所以只写进日志。音源拒绝时的原因本来就是写给用户的,照旧使用。 const UnavailableReason = "暂时无法获取这首歌,可以稍后再点一次" // Reason 为空表示成功,结果已经写入曲目表。 diff --git a/internal/app/port/link.go b/internal/app/port/link.go index 5686c8b..289a1d7 100644 --- a/internal/app/port/link.go +++ b/internal/app/port/link.go @@ -34,12 +34,12 @@ const ( var ErrAudioTooLarge = fmt.Errorf("音频文件超过 %d MB", MaxAudioSize>>20) type LinkDownloader interface { - // 读完文件之后调用 release 删除它们;返回错误时没有要删除的文件。链接不受支持、指向非公网地址, + // 调用方读完文件之后调用 release 删除它们。返回错误时没有要删除的文件。链接不受支持、指向非公网地址, // 或者下载时发现音频超过 MaxAudioSize 时返回 *RejectedError。 Download(ctx context.Context, link string) (d LinkDownload, release func(), err error) } -// 用 yt-dlp 下载链接之前调用。这个链接不需要 cookies 时 ok 为 false;否则下载结束后调用 release,由实现删除文件。 +// 用 yt-dlp 下载链接之前调用。这个链接不需要 cookies 时 ok 为 false。否则调用方在下载结束后调用 release,由实现删除文件。 type LinkCookies interface { CookieFile(ctx context.Context, link string) (path string, release func(), ok bool, err error) } diff --git a/internal/app/port/listen.go b/internal/app/port/listen.go index 7304135..016e355 100644 --- a/internal/app/port/listen.go +++ b/internal/app/port/listen.go @@ -18,7 +18,7 @@ type Listen struct { } type ListenRecorder interface { - // 只记给开启了记录的成员。同一首歌已有记录时 HeardAt 取较晚的,元数据用新的; + // 只记给开启了记录的成员。同一首歌已有记录时,HeardAt 取较晚的那个,元数据用新的。 // 每个成员超过 ListenLimit 首的部分随即删掉。 RecordListen(ctx context.Context, memberIDs []string, l Listen) error } diff --git a/internal/app/port/media.go b/internal/app/port/media.go index 10284cd..b1069a8 100644 --- a/internal/app/port/media.go +++ b/internal/app/port/media.go @@ -9,7 +9,7 @@ import ( ) type MediaStore interface { - // body 要能 Seek:签名要先读一遍算出摘要,失败重试时也要从头再读。 + // body 要能 Seek,因为签名要先读一遍算出摘要,失败重试时也要从头再读。 Put(ctx context.Context, key string, body io.ReadSeeker, contentType string) error Exists(ctx context.Context, key string) (bool, error) // 对象不存在时同样成功。 @@ -17,7 +17,7 @@ type MediaStore interface { } type StoredMedia struct { - // 曲目 ID 的 SHA-256(十六进制)。链接的曲目 ID 是 http(s) 地址,B 站的是 BV 号,两种 ID 不会相同,所以单凭它就能找到这首歌。 + // 曲目 ID 的 SHA-256(十六进制)。链接的曲目 ID 是 http(s) 地址,B 站的是 BV 号,两种 ID 不会相同,所以单凭 Key 就能找到这首歌。 Key string Source track.Source // 曲目 ID:链接曲目是链接本身,B 站的歌是 BV 号。 @@ -26,14 +26,14 @@ type StoredMedia struct { Artist string // 音频的字节数。 Size int64 - // 两者的含义与 track.Meta 的同名字段相同。 + // CoverURL 和 CoverKey 的含义与 track.Meta 的同名字段相同。 CoverURL string CoverKey track.ObjectKey StoredAt time.Time LastPlayedAt *time.Time } -// 每种排序都以曲目 ID 作为最后的排序键,翻页时不重复也不遗漏。 +// 每种排序都以曲目 ID 作为最后的排序键,这样翻页时不重复也不遗漏。 type MediaSort uint8 const ( @@ -51,7 +51,7 @@ type MediaQuery struct { // 从 1 开始,超出最后一页时返回空的一页。 Page int Sort MediaSort - // 标题包含它就算匹配,不区分大小写,按字面匹配;为空表示不搜索。 + // 标题包含 Title 就算匹配,不区分大小写,按字面匹配。为空表示不搜索。 Title string } diff --git a/internal/app/port/member.go b/internal/app/port/member.go index 8e2e611..41127c4 100644 --- a/internal/app/port/member.go +++ b/internal/app/port/member.go @@ -60,20 +60,20 @@ type Members interface { AccountByID(ctx context.Context, memberID string) (Account, error) AccountByGitHub(ctx context.Context, githubID int64) (Account, error) // 这个 GitHub 账号已经绑定了其他成员时返回 ErrGitHubTaken,成员已经绑定了 GitHub 时返回 ErrGitHubAlreadyBound,两种情况都不做修改。 - // 成员原来是游客时,绑定就是注册:同时为这个账号创建“我喜欢”。 + // 成员原来是游客时,绑定就是注册,同时为这个账号创建“我喜欢”。 BindGitHub(ctx context.Context, memberID string, githubID int64) error - // 账号没有设置密码时返回 ErrNoPassword:解绑之后它就再也无法登录。没有绑定 GitHub 时什么也不做。 + // 账号没有设置密码时返回 ErrNoPassword,因为解绑之后这个账号就再也无法登录。没有绑定 GitHub 时什么也不做。 UnbindGitHub(ctx context.Context, memberID string) error // 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。oldTokenHash 的会话已经不存在时返回 - // ErrSessionNotFound;memberID 的密码哈希已经不是 passwordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改过, - // 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。 - // 同一个事务中把 oldTokenHash 所属成员的播放历史移到 memberID,并入的规则与 ListenRecorder.RecordListen 相同, - // memberID 关闭了记录时不并入;两者是同一个成员时不动。 + // ErrSessionNotFound。memberID 的密码哈希已经不是 passwordHash 时返回 ErrAccountPasswordChanged。这说明密码在校验之后被修改过。 + // 修改密码时删除会话的那一步已经执行过,如果这时创建会话,这个会话就不会被删掉。两种情况都不做任何修改。 + // 同一个事务中还把 oldTokenHash 所属成员的播放历史移到 memberID,并入的规则与 ListenRecorder.RecordListen 相同。 + // memberID 关闭了记录时不并入,oldTokenHash 所属的成员就是 memberID 时不动。 ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error DeleteSession(ctx context.Context, tokenHash []byte) error // 在同一个事务中修改已注册成员的密码哈希,并删除该成员的全部会话。返回被删除的会话的 token 哈希。成员是游客时返回 ErrMemberNotFound。 SetPassword(ctx context.Context, memberID, passwordHash string) ([][]byte, error) - // 与 SetPassword 相同,但保留发出请求的 keepTokenHash 的会话。这个会话已经不存在时返回 ErrSessionNotFound;密码哈希已经不是 - // 校验当前密码时读到的 currentPasswordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改或重置过。两种情况都不做任何修改。 + // 与 SetPassword 相同,但保留发出请求的 keepTokenHash 的会话。这个会话已经不存在时返回 ErrSessionNotFound。密码哈希已经不是 + // 校验当前密码时读到的 currentPasswordHash 时返回 ErrAccountPasswordChanged,说明密码在校验之后被修改或重置过。两种情况都不做任何修改。 ChangePassword(ctx context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) ([][]byte, error) } diff --git a/internal/app/port/pairing.go b/internal/app/port/pairing.go index a41e1c9..dbe0ffd 100644 --- a/internal/app/port/pairing.go +++ b/internal/app/port/pairing.go @@ -6,13 +6,13 @@ import ( "time" ) -// 配对码、GitHub 登录的 state 和兑换码都只能用一次:取出即删除,到期自动失效。 +// 配对码、GitHub 登录的 state 和兑换码都只能用一次,取出即删除,到期自动失效。 var ErrCodeNotFound = errors.New("不存在或已经过期") -// 已注册成员生成的配对码:新设备兑换之后登录这个账号。 +// 已注册成员生成的配对码。新设备兑换配对码之后就登录了这个账号。 type Pairing struct { MemberID string - // 生成配对码的会话:它被删除之后(退出登录、修改或重置密码),配对码随之作废。 + // 生成配对码的会话。这个会话被删除之后(退出登录、修改或重置密码),配对码随之作废。 SessionTokenHash []byte } diff --git a/internal/app/port/playlist.go b/internal/app/port/playlist.go index 4e94ea4..1df00d4 100644 --- a/internal/app/port/playlist.go +++ b/internal/app/port/playlist.go @@ -38,7 +38,7 @@ type PlaylistItem struct { Meta track.Meta } -// 分享页上显示的内容:不带所有者的成员 ID 和用户名。 +// 分享页上显示的内容,不带所有者的成员 ID 和用户名。 type SharedPlaylist struct { Name string Favorites bool @@ -48,13 +48,14 @@ type SharedPlaylist struct { Items []PlaylistItem } -// 所有方法都按 ownerID 过滤:别人的歌单和不存在的歌单一样,返回 ErrPlaylistNotFound。“我喜欢”由 Members.Register 创建。 +// SharedPlaylist 和 CopySharedPlaylist 按 shareID 读取分享出来的歌单,不限所有者。其余方法都按 ownerID 过滤,别人的歌单和不存在的歌单一样返回 ErrPlaylistNotFound。 +// “我喜欢”由 Members.Register 创建,游客绑定 GitHub 时由 Members.BindGitHub 创建。 type Playlists interface { // “我喜欢”在最前,其余按创建的先后。Cover 是歌单里排在最前、有封面的那首歌的封面,没有时为 nil。 MemberPlaylists(ctx context.Context, ownerID string) ([]PlaylistRecord, error) // 已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。 CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (PlaylistRecord, error) - // 新建歌单并写入 items,items[0] 排在最前;返回的记录带曲目数和封面。已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。 + // 新建歌单并写入 items,items[0] 排在最前。返回的记录带曲目数和封面。已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。 // 调用方保证 items 中没有重复的歌、不超过歌单的曲目上限。 ImportPlaylist(ctx context.Context, ownerID, name string, items []PlaylistItem, limit int) (PlaylistRecord, error) // “我喜欢”返回 ErrFavoritesFixed。 @@ -63,14 +64,14 @@ type Playlists interface { DeletePlaylist(ctx context.Context, ownerID, id string) error // 新加入的在前。 PlaylistItems(ctx context.Context, ownerID, id string) ([]PlaylistItem, error) - // 已经在歌单里时什么也不做;已有 limit 首时返回 ErrPlaylistLimit。 + // 这首歌已经在歌单里时什么也不做。歌单已有 limit 首时返回 ErrPlaylistLimit。 AddPlaylistItem(ctx context.Context, ownerID, id string, item PlaylistItem, limit int) error // 不在歌单里的歌忽略。 RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error - // 只看这个成员自己的歌单;没有命中时返回空切片。 + // 只看这个成员自己的歌单。没有命中时返回空切片。 TrackPlaylists(ctx context.Context, ownerID string, t track.Track) ([]string, error) // 把 fromID 歌单里的这些歌连同元数据复制到 toID 歌单,返回复制的数量。fromID 不属于 ownerID 时返回 ErrPlaylistNotFound。 - // 不在 fromID 里的和已在 toID 里的跳过;新复制的排在最前,tracks[0] 最靠前。 + // 不在 fromID 里的和已在 toID 里的歌跳过。新复制的歌排在最前,tracks[0] 最靠前。 // 复制之后超过 limit 首时整批拒绝,返回 ErrPlaylistLimit。 CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) // 按 playlist.Move 的规则调整顺序,错误原样返回。 @@ -79,7 +80,7 @@ type Playlists interface { SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error // 没有歌单使用这个 shareID 时返回 ErrShareNotFound。 SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylist, error) - // 新建 ownerID 的歌单,复制 shareID 对应歌单的全部曲目,顺序不变;返回的记录带曲目数和封面。已有 limit 个歌单(含“我喜欢”)时 + // 新建 ownerID 的歌单,复制 shareID 对应歌单的全部曲目,顺序不变。返回的记录带曲目数和封面。已有 limit 个歌单(含“我喜欢”)时 // 返回 ErrPlaylistLimit,没有歌单使用这个 shareID 时返回 ErrShareNotFound,两种情况都不新建歌单。 CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (PlaylistRecord, error) } diff --git a/internal/app/port/porttest/login.go b/internal/app/port/porttest/login.go index 8e8e781..8635d47 100644 --- a/internal/app/port/porttest/login.go +++ b/internal/app/port/porttest/login.go @@ -19,7 +19,7 @@ const ( WeChatQRImage = "https://wx.example/qr.png" ) -// 查询时不检查会话的过期时间;登录成功时也不像真实的音源那样保存登录状态。 +// 查询时不检查会话的过期时间。登录成功时也不像真实的音源那样保存登录状态。 type qrLogins struct { mu sync.Mutex created int diff --git a/internal/app/port/porttest/redis.go b/internal/app/port/porttest/redis.go index e8f809a..090abea 100644 --- a/internal/app/port/porttest/redis.go +++ b/internal/app/port/porttest/redis.go @@ -61,7 +61,7 @@ func (r *Redis) Flush() { clear(r.alive) } -// SetDown 模拟本实例连不上 Redis:之后的操作都返回 ErrRedisDown;订阅连接随之断开,下一条收到的消息是 +// SetDown 模拟本实例连不上 Redis。之后的操作都返回 ErrRedisDown,订阅连接也随之断开,下一条收到的消息是 // SubscriptionLost。恢复之后仍然订阅着原来的频道,断开期间发布的消息不会补回。 func (n *Node) SetDown(down bool) { r := n.redis diff --git a/internal/app/port/porttest/snapshot.go b/internal/app/port/porttest/snapshot.go index ee23c5b..0f1d57d 100644 --- a/internal/app/port/porttest/snapshot.go +++ b/internal/app/port/porttest/snapshot.go @@ -50,7 +50,7 @@ func (s *Store) TakeRoom(_ context.Context, id string) (uint32, room.Snapshot, e return st.term, snap, nil } -// postgres 适配器按曲目表判断条目是否就绪,这里没有曲目表,恢复出的条目都在准备中。 +// postgres 适配器按曲目表判断条目是否就绪。内存实现没有曲目表,所以恢复出的条目都在准备中。 func restored(e room.Entry) room.Entry { e.Ready = false e.Prep = track.Prepared{} diff --git a/internal/app/port/porttest/source.go b/internal/app/port/porttest/source.go index f691179..ff30f53 100644 --- a/internal/app/port/porttest/source.go +++ b/internal/app/port/porttest/source.go @@ -239,7 +239,7 @@ func SilentWAV(d time.Duration) []byte { return append(b, bytes.Repeat([]byte{128}, frames)...) } -// 1 kHz 的正弦波,峰值是满幅的 amplitude 倍:响度分析得到的增益不是 1。 +// 1 kHz 的正弦波,峰值是满幅的 amplitude 倍,用来让响度分析得到不是 1 的增益。 func ToneWAV(d time.Duration, amplitude float64) []byte { const rate, channels = 44100, 2 frames := int(d * rate / time.Second) diff --git a/internal/app/port/porttest/store.go b/internal/app/port/porttest/store.go index a924ac6..1ff1636 100644 --- a/internal/app/port/porttest/store.go +++ b/internal/app/port/porttest/store.go @@ -275,7 +275,7 @@ func (s *Store) UnbindGitHub(_ context.Context, memberID string) error { s.mu.Lock() defer s.mu.Unlock() m := s.member(memberID) - // 密码总是和用户名一起设置:没有用户名,就是 PostgreSQL 中 password_hash 为 NULL。 + // 密码总是和用户名一起设置,所以没有用户名就相当于 PostgreSQL 中 password_hash 为 NULL。 if m == nil || m.username == "" { return port.ErrNoPassword } @@ -363,7 +363,7 @@ func (s *Store) deleteSessions(memberID string, keepTokenHash []byte) [][]byte { return deleted } -// 与 postgres 的外键一致:会话的票据随会话一起删除。调用方持有 s.mu。 +// 与 postgres 的外键一致,会话的票据随会话一起删除。调用方持有 s.mu。 func (s *Store) deleteSession(hash string) { delete(s.sessions, hash) maps.DeleteFunc(s.tickets, func(_ string, t storedTicket) bool { return t.session == hash }) diff --git a/internal/app/port/room.go b/internal/app/port/room.go index 9490c70..14ed7af 100644 --- a/internal/app/port/room.go +++ b/internal/app/port/room.go @@ -40,6 +40,6 @@ type RoomDirectory interface { ListRooms(ctx context.Context) ([]RoomRecord, error) // ref 是房间的 UUID 或别名。 FindRoom(ctx context.Context, ref string) (RoomRecord, error) - // 设置或修改密码时同时删除房间成员的票据,bot 成员的票据(API Key)保留;调用方随后还要让这个房间的在线会话断开。 + // 设置或修改密码时同时删除房间成员的票据,bot 成员的票据(API Key)保留。调用方随后还要让这个房间的在线会话断开。 UpdateRoomAccess(ctx context.Context, id string, c AccessChange) error } diff --git a/internal/app/port/shortlink.go b/internal/app/port/shortlink.go index 7febbae..2cc8a14 100644 --- a/internal/app/port/shortlink.go +++ b/internal/app/port/shortlink.go @@ -3,6 +3,6 @@ package port import "context" type ShortLinks interface { - // 返回跳转之后的最终地址;跳转到不受信任的主机、跳转过多或超时都返回错误。 + // 返回跳转之后的最终地址。跳转到不受信任的主机、跳转过多或超时都返回错误。 Resolve(ctx context.Context, link string) (string, error) } diff --git a/internal/app/port/snapshot.go b/internal/app/port/snapshot.go index f8eda3f..2b4f480 100644 --- a/internal/app/port/snapshot.go +++ b/internal/app/port/snapshot.go @@ -12,7 +12,7 @@ var ErrTermExpired = errors.New("任期已过期") type RoomSnapshots interface { // 任期加 1 之后,之前任期的写入都会失败,所以只有接管房间的实例才能调用。 - // 条目是否就绪只按曲目表判断;链接和 B 站的歌的音频是否还在对象存储中,由调用方检查。 + // 条目是否就绪只按曲目表判断。链接和 B 站的歌的音频是否还在对象存储中,由调用方检查。 TakeRoom(ctx context.Context, id string) (uint32, room.Snapshot, error) SaveRoom(ctx context.Context, term uint32, s room.Snapshot, plays []room.RecordPlay) error DeleteRoom(ctx context.Context, id string, term uint32) error diff --git a/internal/app/port/source.go b/internal/app/port/source.go index 76895e4..48a3518 100644 --- a/internal/app/port/source.go +++ b/internal/app/port/source.go @@ -20,9 +20,9 @@ const ( type QRLoginSession struct { Key string - // 二维码编码的登录页链接;平台只给图片时为空。 + // 二维码编码的登录页链接。平台只给图片时为空。 LoginURL string - // 浏览器可以直接显示的二维码图片:data: URI 或 https 地址;平台只给登录链接时为空。两者至少有一个。 + // 浏览器可以直接显示的二维码图片,是 data: URI 或 https 地址。平台只给登录链接时为空。LoginURL 和 QRImage 至少有一个不为空。 QRImage string ExpiresAt time.Time } @@ -58,8 +58,8 @@ type QQQRLoginSource interface { CurrentAccount(context.Context) (*SourceAccount, error) } -// RejectedError 表示音源明确拒绝了这首歌,比如歌曲下架或没有版权:准备不再重试。 -// 它的文本就是准备失败的原因。 +// RejectedError 表示音源明确拒绝了这首歌,比如歌曲下架或没有版权,所以准备不再重试。 +// RejectedError 的文本就是准备失败的原因。 type RejectedError struct { Err error } diff --git a/internal/app/port/ticket.go b/internal/app/port/ticket.go index 9148548..71bacbd 100644 --- a/internal/app/port/ticket.go +++ b/internal/app/port/ticket.go @@ -18,7 +18,7 @@ type NewTicket struct { MemberID string // 签发前校验密码时依据的密码哈希,空字符串表示房间没有密码。 PasswordHash string - // 发出请求的会话的 token 哈希:会话删除时票据随之失效。 + // 发出请求的会话的 token 哈希。这个会话删除时,票据随之失效。 SessionTokenHash []byte } @@ -33,13 +33,13 @@ type TicketHolder struct { } type Tickets interface { - // 房间的密码哈希已经不是 t.PasswordHash 时返回 ErrPasswordChanged:密码在校验之后被修改过, - // 设置或修改密码时的作废已经执行,此时签发的票据不会再被作废。t.SessionTokenHash 的会话已经不存在时返回 ErrSessionNotFound。 + // 房间的密码哈希已经不是 t.PasswordHash 时返回 ErrPasswordChanged。这说明密码在校验之后被修改过。 + // 设置或修改密码时作废票据的那一步已经执行过,如果这时签发票据,这张票据就不会被作废。t.SessionTokenHash 的会话已经不存在时返回 ErrSessionNotFound。 CreateTicket(ctx context.Context, t NewTicket) error TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolder, error) } -// 网关定期用它核对连接所用的凭证,不在存储中的当作已撤销而断开。所以票据只在作废时删除: +// 网关定期用 ExistingTickets 核对连接所用的凭证。凭证不在存储中,就当作已撤销,断开连接。所以票据只在作废时删除。 // 如果按时间清理过期的票据,仍在使用这些票据的连接也会被断开。 type ExistingTickets interface { // 返回 tokenHashes 中仍然存在的票据(包括 API Key),每个只出现一次,顺序不定。 diff --git a/internal/app/port/tracks.go b/internal/app/port/tracks.go index 5f0832c..33bb93f 100644 --- a/internal/app/port/tracks.go +++ b/internal/app/port/tracks.go @@ -10,7 +10,7 @@ import ( type PreparedTrack struct { Meta track.Meta Result track.Prepared - // 链接和 B 站的歌的 MediaKey 是曲目 ID 的 SHA-256(十六进制),对象存储中音频和封面的对象键由它得出; + // 链接和 B 站的歌的 MediaKey 是曲目 ID 的 SHA-256(十六进制),对象存储中音频和封面的对象键由 MediaKey 得出。 // MediaSize 是音频的字节数。其他来源为空。 MediaKey string MediaSize int64 diff --git a/internal/app/prepare/bilibili_test.go b/internal/app/prepare/bilibili_test.go index 24ffd76..e0cb125 100644 --- a/internal/app/prepare/bilibili_test.go +++ b/internal/app/prepare/bilibili_test.go @@ -71,7 +71,7 @@ func TestMediaRunnerRejectsBadBilibiliTasks(t *testing.T) { if err := f.run(t, track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD"}); !errors.As(err, &rejected) || err.Error() != "这个视频需要付费" { t.Errorf("音源拒绝下载时 = %v,期望不能准备,原因是音源给出的", err) } - // 音源里也登记这些 ID:交给音源就会下载成功。 + // 音源里也登记这些 ID,这样只要交给音源,就会下载成功。 for _, id := range []string{"BV1xx411c7mD/1", "../BV1xx411c7mD", ""} { f.source.AddVideo(id, track.Meta{Title: "格式不对的 ID"}, []byte("平台给的原始音频"), nil, "") if err := f.run(t, track.Track{Source: track.Bilibili, ID: id}); !errors.As(err, &rejected) { diff --git a/internal/app/prepare/media.go b/internal/app/prepare/media.go index d02d7ec..039b803 100644 --- a/internal/app/prepare/media.go +++ b/internal/app/prepare/media.go @@ -17,8 +17,8 @@ import ( const cleanupTimeout = 10 * time.Second -// 限定封面格式并不能阻止脚本:链接的封面来自用户提交的网页,而对象存储会按匿名请求中的 response-content-type 改写 Content-Type。 -// 对象存储与页面同源时,要靠入口为 /storage/ 的响应添加 Content-Security-Policy: sandbox 和 X-Content-Type-Options: nosniff 来防范脚本。 +// 限定封面格式并不能阻止脚本,因为链接的封面来自用户提交的网页,而对象存储会按匿名请求中的 response-content-type 改写 Content-Type。 +// 对象存储与页面同源时,要靠入口给 /storage/ 的响应加上 Content-Security-Policy: sandbox 和 X-Content-Type-Options: nosniff 来防范脚本。 var coverTypes = map[string]string{"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp"} // MediaRunner 准备音频和封面要存进对象存储的歌:链接由 yt-dlp 下载并转成 mp3,B 站的歌由音源下载原始文件,再转成 mp3。 @@ -36,7 +36,7 @@ func NewMediaRunner(links port.LinkDownloader, bilibili port.BilibiliSource, tra } func (r *MediaRunner) Run(ctx context.Context, t track.Track) (err error) { - // 任务可能在写入结果之后、River 记录完成之前中断,再次执行时结果仍然有效:往下走的话,clear 会删掉房间正在使用的音频。 + // 任务可能在写入结果之后、River 记录完成之前中断,再次执行时结果仍然有效。这时不能往下走,否则 clear 会删掉房间正在使用的音频。 if done, err := r.prepared(ctx, t); done || err != nil { return err } @@ -53,7 +53,7 @@ func (r *MediaRunner) Run(ctx context.Context, t track.Track) (err error) { if err := r.clear(ctx, key); err != nil { return err } - // 上传开始之后任何一步失败,都要删除已经上传的对象:它们匿名可读,却没有准备结果,后台也看不到,不会再有人清理。 + // 上传开始之后任何一步失败,都要删除已经上传的对象。这些对象匿名可读,却没有准备结果,后台也看不到,不会再有人清理。 defer func() { if err != nil { r.discard(ctx, t, key) @@ -70,7 +70,7 @@ func (r *MediaRunner) Run(ctx context.Context, t track.Track) (err error) { wg.Go(func() { result, analyzeErr = analyzeFile(ctx, r.logger, t, d.Audio, d.Meta.Duration) }) size, coverKey, uploadErr = r.upload(ctx, key, d) wg.Wait() - // 音频无法分析时重试也没有用,先于上传的错误返回。 + // 音频无法分析时重试也没有用,所以分析的错误先于上传的错误返回。 if analyzeErr != nil { return analyzeErr } @@ -108,7 +108,7 @@ func (r *MediaRunner) downloadBilibili(ctx context.Context, id string) (port.Lin if err != nil { return port.LinkDownload{}, nil, err } - // 用 defer 是为了 panic 时也删除文件:River 会恢复任务中的 panic,worker 继续运行。 + // 用 defer 是为了 panic 时也删除文件,因为 River 会恢复任务中的 panic,worker 还会继续运行。 done := false defer func() { if !done { @@ -163,7 +163,7 @@ func pageURL(t track.Track) string { return t.ID } -// 对象键由曲目 ID 决定,重新准备时要先清除上一次留下的对象:这一次封面的格式可能不同,也可能没有封面。 +// 对象键由曲目 ID 决定。重新准备时要先清除上一次留下的对象,因为这一次封面的格式可能不同,也可能没有封面。 func (r *MediaRunner) clear(ctx context.Context, key string) error { errs := []error{r.store.Delete(ctx, media.AudioObject(key))} for ext := range coverTypes { @@ -172,7 +172,7 @@ func (r *MediaRunner) clear(ctx context.Context, key string) error { return errors.Join(errs...) } -// 尽力删除已经上传的对象,失败只记 WARN:下一次准备同一首歌时还会再清理一遍。ctx 可能已经结束(任务被取消或超时), +// 尽力删除已经上传的对象,失败只记 WARN,因为下一次准备同一首歌时还会再清理一遍。ctx 可能已经结束(任务被取消或超时), // 所以使用脱离 ctx 的短期限。 func (r *MediaRunner) discard(ctx context.Context, t track.Track, key string) { ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cleanupTimeout) @@ -201,7 +201,7 @@ func analyzeFile(ctx context.Context, logger *slog.Logger, t track.Track, audio return analyze(ctx, logger, t, ctxReader{ctx: ctx, r: f}, fallback, "") } -// 解码不接收 ctx,只能靠这层包装在 ctx 结束后让读取失败;否则任务取消或超时之后,长文件的分析仍会继续。 +// 解码不接收 ctx,只能靠 ctxReader 在 ctx 结束后让读取失败。否则任务取消或超时之后,长文件的分析仍会继续。 type ctxReader struct { ctx context.Context r io.Reader diff --git a/internal/app/prepare/media_test.go b/internal/app/prepare/media_test.go index 9cfd29c..2900cfc 100644 --- a/internal/app/prepare/media_test.go +++ b/internal/app/prepare/media_test.go @@ -123,7 +123,7 @@ func TestLinkRunnerSkipsCoverOfOtherFormats(t *testing.T) { } } -// 元数据里有时长:没有检查大小时,内容全是零的音频会按元数据的时长准备成功,而不是因为无法分析而失败。 +// 元数据里有时长。如果不检查大小,内容全是零的音频会按元数据的时长准备成功,而不是因为无法分析而失败。 func TestLinkRunnerRejectsAudioOverLimit(t *testing.T) { d := &downloader{meta: track.Meta{Title: "晴天", Duration: 3 * time.Second}, audio: make([]byte, port.MaxAudioSize+1), cover: []byte("jpeg"), coverExt: "jpg"} store := porttest.NewMediaStore() diff --git a/internal/app/prepare/prepare.go b/internal/app/prepare/prepare.go index 363a6ee..fbbb4e0 100644 --- a/internal/app/prepare/prepare.go +++ b/internal/app/prepare/prepare.go @@ -68,7 +68,7 @@ func (p *Preparer) Finished(o port.PrepareOutcome) { p.mu.Lock() defer p.mu.Unlock() for ch := range p.waiting[o.Track] { - // 只保留最新的结果:较早的结果属于已经结束的任务。 + // 只保留最新的结果,因为较早的结果属于已经结束的任务。 select { case <-ch: default: @@ -87,7 +87,7 @@ func (p *Preparer) prepared(ctx context.Context, t track.Track) (port.PreparedTr return result, ok, err } -// 返回之后要跟进的任务,提交失败时为 0。任务已经完成时返回 again,由调用方重新读取曲目表:结果可能是任务在刚才读取曲目表之后才写入的, +// 返回之后要跟进的任务,提交失败时为 0。任务已经完成时返回 again,由调用方重新读取曲目表。任务可能是在刚才读取曲目表之后才写入结果的, // 这时不用再提交任务。 func (p *Preparer) follow(ctx context.Context, t track.Track, job int64) (int64, string, bool) { if job != 0 { diff --git a/internal/app/prepare/prepare_test.go b/internal/app/prepare/prepare_test.go index 1be8c6b..5f1b09d 100644 --- a/internal/app/prepare/prepare_test.go +++ b/internal/app/prepare/prepare_test.go @@ -119,7 +119,7 @@ func start(p *Preparer, ctx context.Context, t track.Track) <-chan prepared { func discard() *slog.Logger { return slog.New(slog.DiscardHandler) } -// synctest 的时钟随核对的定时器不断推进,Prepare 不返回也不会被判定为死锁:必须设期限,否则出错时要等到包超时才失败。 +// synctest 的时钟随核对的定时器不断推进,Prepare 一直不返回也不会被判定为死锁。所以必须设期限,否则出错时要等到包超时才失败。 func await(t *testing.T, done <-chan prepared) prepared { t.Helper() select { diff --git a/internal/app/realtime/cluster_test.go b/internal/app/realtime/cluster_test.go index b0a1a92..9cd1c4a 100644 --- a/internal/app/realtime/cluster_test.go +++ b/internal/app/realtime/cluster_test.go @@ -173,7 +173,7 @@ func newFlakyBusInstance(t *testing.T, store *porttest.Store, logger *slog.Logge return h, g, node, bus } -// 本实例的频道订阅生效之前不接管房间:此时收不到其他实例转发来的输入,接管之后房间的回复也可能漏掉。 +// 本实例的频道订阅生效之前不接管房间,因为这时收不到其他实例转发来的输入,接管之后房间的回复也可能漏掉。 func TestNoTakeoverBeforeInstanceChannelIsSubscribed(t *testing.T) { synctest.Test(t, func(t *testing.T) { store := porttest.New() @@ -780,7 +780,7 @@ func TestRemoteNotFoundMeansRoomNotFound(t *testing.T) { a := startInstance(t, redis, "a", store, store, songs) b := startInstance(t, redis, "b", store, store, songs) synctest.Wait() - // 房间的数据已经删除,租约却还属于 a:a 接管时找不到房间。 + // 房间的数据已经删除,租约却还属于 a,所以 a 接管时找不到房间。 missing := "01a15778-1911-7a28-8b2e-e08c2a25f388" if ok, err := a.node.Acquire(t.Context(), missing, leaseTTL); err != nil || !ok { t.Fatalf("a 取得租约 = %v, %v", ok, err) diff --git a/internal/app/realtime/context_test.go b/internal/app/realtime/context_test.go index 4c45325..df93267 100644 --- a/internal/app/realtime/context_test.go +++ b/internal/app/realtime/context_test.go @@ -107,7 +107,7 @@ func TestFailedRequestsCarryTheRequestIDOfTheOwnerLogs(t *testing.T) { a := f.join(t, id, alice) store.fail.Store(true) - // 房间被放弃之后要等这次运行结束,下一个请求才会重新接管它并再次落盘;否则下一个请求可能遇到正在结束的运行,不经落盘就失败。 + // 房间被放弃之后要等这次运行结束,下一个请求才会重新接管房间并再次落盘。否则下一个请求可能遇到正在结束的运行,不经落盘就失败。 _, doErr := f.gateway.Do(t.Context(), id, bob, room.SetPlayMode{Mode: room.Random}) synctest.Wait() if !a.Begin() { diff --git a/internal/app/realtime/gateway.go b/internal/app/realtime/gateway.go index 86fdc23..68816d4 100644 --- a/internal/app/realtime/gateway.go +++ b/internal/app/realtime/gateway.go @@ -50,14 +50,14 @@ type Gateway struct { // 每个房间的事件流按打开的顺序排列。 observers map[string][]*Observer links map[string]*roomLink - // 订阅连接每断开一次就加 1:新连接是否已经订阅了这些频道,要等确认才知道,所以之前确认过的房间订阅要重新确认。 + // 订阅连接每断开一次就加 1。roomLink.confirmed 记着这个房间在哪一代连接上确认过订阅。和当前代数不同,说明新连接上不一定有这个订阅,要重新订阅一次。 epoch uint64 active sync.WaitGroup stopped chan struct{} } -// RequestError 是网关为房间请求生成 RequestID 之后,该请求失败时返回的错误。owner 处理这个请求时的日志带有同一个 ID, -// 调用方记录失败时带上它,网关和 owner 两侧的日志就能对应起来。 +// RequestError 是网关为房间请求生成 RequestID 之后,这个请求失败时返回的错误。owner 处理这个请求时的日志带有同一个 ID, +// 调用方记录失败时带上这个 ID,网关和 owner 两侧的日志就能对应起来。 type RequestError struct { ID room.RequestID Err error @@ -74,7 +74,7 @@ func failed(id room.RequestID, err error) error { } type pendingRequest struct { - // 由从 pending 中取走请求的一方调用一次,然后关闭 done;在接收消息的 goroutine 上调用时不能阻塞。 + // 谁把请求从 pending 里取走,谁就调用一次 deliver,再关闭 done。deliver 可能在接收消息的 goroutine 上执行,所以不能阻塞。 deliver func(room.Result, error) done chan struct{} } @@ -108,7 +108,7 @@ func New(snapshots port.RoomSnapshots, directory port.RoomDirectory, listens por return h, g } -// memberSession 是票据签发给的成员会话,credential 是票据在存储中的哈希。会话结束时必须调用 Leave。 +// memberSession 是票据所属的成员会话,credential 是票据在存储中的哈希。会话结束时必须调用 Leave。 func (g *Gateway) Join(ctx context.Context, roomID string, m member.Member, memberSession, credential []byte) (*Session, error) { link, err := g.hold(ctx, roomID) if err != nil { @@ -152,7 +152,7 @@ func (g *Gateway) Leave(s *Session) { } g.mu.Unlock() s.left.Store(true) - // 第一次发送在调用方的 goroutine 上进行:Shutdown 会等会话 Leave,它返回时 Leave 已经交给 owner;发送失败的才交给后台重试。 + // 第一次发送在调用方的 goroutine 上进行。这样 Shutdown 等到会话 Leave 返回时,Leave 已经交给了 owner。发送失败的才交给后台重试。 if leave := leaveOf(s); !g.sendPresence(s.room, false, leave) { go g.retryPresence(s.room, false, leave) } @@ -182,7 +182,7 @@ func (g *Gateway) Do(ctx context.Context, roomID string, m member.Member, a room return res, failed(id, err) } -// 参数为 nil 表示保持不变;房间落盘之后才返回。 +// 参数为 nil 表示保持不变。房间落盘之后才返回。 func (g *Gateway) UpdateInfo(ctx context.Context, roomID string, name, description *string) error { id := g.newRequestID() var err error @@ -202,8 +202,8 @@ func (g *Gateway) Delete(ctx context.Context, roomID string) error { return failed(id, err) } -// 以下三个通知在凭证从存储中删除之后发出,只是让连接立即断开的快速路径:发起请求的客户端此时断开也照样发出, -// 发不出去时只记日志。通知丢失时,各实例定期核对连接所用的凭证,最迟 credentialCheckInterval 之后断开。 +// 以下三个通知在凭证从存储中删除之后发出,只是让连接立即断开的快速路径。即使发起请求的客户端此时已经断开,通知也照样发出, +// 发不出去时只记日志。通知丢失时,各实例定期核对连接所用的凭证,连接最迟在 credentialCheckInterval 之后断开。 // Revoke 在房间成员的票据作废之后通知各实例,以 CloseRevoked 关闭这个房间的会话和成员打开的事件流。 func (g *Gateway) Revoke(ctx context.Context, roomID string) { @@ -244,7 +244,7 @@ func (g *Gateway) MemberUpdated(ctx context.Context, m member.Member) error { return nil } -// 广播失败时只记录日志:等待的一方会定期按任务 ID 核对。 +// 广播失败时只记录日志,因为等待的一方会定期按任务 ID 核对。 func (g *Gateway) PublishPrepared(o port.PrepareOutcome) { ctx, cancel := context.WithTimeout(logctx.WithJob(context.Background(), o.JobID), clusterTimeout) defer cancel() @@ -318,7 +318,8 @@ func (g *Gateway) Resolve(ctx context.Context, roomID string, by member.Member, } } -// deliver 恰好被调用一次:收到回复时在接收消息的 goroutine 上调用,失败时在这里调用;返回时 deliver 已经调用过。 +// deliver 恰好被调用一次,request 返回时 deliver 已经调用过。收到 Replied 或 Rejected 时,deliver 在接收消息的 goroutine 上调用。 +// 订阅房间频道失败、输入发不出去、超时或者调用方放弃时,deliver 在 request 里调用。订阅连接断开过之后,restart 以 ErrUnavailable 调用 deliver。 // 网关先确认订阅了房间频道再发出输入,因为回复和事件都经由房间频道到达。 func (g *Gateway) request(ctx context.Context, roomID string, id room.RequestID, e envelope, http bool, deliver func(room.Result, error)) { p := &pendingRequest{deliver: deliver, done: make(chan struct{})} @@ -369,7 +370,7 @@ func (g *Gateway) route(ctx context.Context, roomID string, e envelope, takeover if err != nil { return unavailable(err) } - // owner 所在的实例没有订阅自己的频道:它已经停止,但租约还没有过期。 + // owner 所在的实例没有订阅自己的频道,说明 owner 已经停止,但租约还没有过期。 if !delivered { return ErrUnavailable } @@ -382,7 +383,7 @@ func unavailable(err error) error { return fmt.Errorf("%w:%w", ErrUnavailable, err) } -// 回复、超时和房间不再处理请求可能同时发生,先从 pending 中取走请求的一方负责送达。 +// 回复、超时和房间不再处理请求可能同时发生,谁先从 pending 中取走请求,谁负责送达。 func (g *Gateway) settle(id room.RequestID, res room.Result, err error) { g.mu.Lock() p, ok := g.pending[id] @@ -405,7 +406,7 @@ func (g *Gateway) deliver(roomID string, m port.RoomMessage) { } } case port.OwnerReleased: - // 房间暂时没有 owner,而这里还有它的会话,所以立即接管。 + // 房间暂时没有 owner,而本实例还有这个房间的会话,所以立即接管。 g.rejoin(roomID, true) case port.Emitted: for _, q := range g.recipients(roomID) { @@ -454,7 +455,7 @@ func (g *Gateway) rejoin(roomID string, takeover bool) { go func() { for _, s := range sessions { g.deliverPresence(roomID, takeover, joinOf(s)) - // 会话在重新加入的途中离开时,它的 Leave 可能排在这次 Join 之前,所以补发一次 Leave,以免它留在在线列表中。 + // 会话在重新加入的途中离开时,会话的 Leave 可能排在这次 Join 之前,所以补发一次 Leave,以免会话留在在线列表中。 if s.left.Load() { g.deliverPresence(roomID, false, leaveOf(s)) } @@ -516,15 +517,15 @@ func (g *Gateway) sessionsRevoked(memberSessions [][]byte) { } } -// owner 靠 Join、Leave 和 MemberUpdated 知道谁在线,这几条消息丢失后没有别的办法补回:离开的会话会一直留在在线列表中, -// 临时房间也因此无法关闭;仍然在线的会话则不在列表中。所以送不到 owner 时要一直重试,直到送达。 -// next 在每次尝试前调用,返回这一次要发的输入;返回 false 表示这条消息已经不必再发。 +// owner 靠 Join、Leave 和 MemberUpdated 知道谁在线,这几条消息丢失后没有别的办法补回。丢失的后果是离开的会话一直留在在线列表中, +// 临时房间也因此无法关闭,或者仍然在线的会话不在列表中。所以送不到 owner 时要一直重试,直到送达。 +// next 在每次尝试前调用,返回这一次要发的输入。返回 false 表示这条消息已经不必再发。 func (g *Gateway) sendPresence(roomID string, takeover bool, next func() (room.Input, bool)) (done bool) { in, ok := next() return !ok || g.route(context.Background(), roomID, envelope{input: in}, takeover) == nil } -// 网关停止之后不再重试:进程即将退出,本实例的存活键过期之后,owner 会让这里的会话离开。 +// 网关停止之后不再重试,因为进程即将退出,等本实例的存活键过期,owner 会让本实例的会话离开。 func (g *Gateway) retryPresence(roomID string, takeover bool, next func() (room.Input, bool)) { for delay := presenceRetryMin; ; delay = min(2*delay, presenceRetryMax) { select { @@ -557,7 +558,7 @@ func leaveOf(s *Session) func() (room.Input, bool) { return func() (room.Input, bool) { return room.Leave{Session: s.id}, true } } -// 每次都发送这个成员在本地会话记录中的最新资料,而不是触发这次发送的那一份:否则资料再次修改之后, +// 每次都发送这个成员在本地会话记录中的最新资料,而不是触发这次发送的那一份。否则资料再次修改之后, // 先前那次发送的重试如果晚到,会把旧资料写回房间。 func (g *Gateway) memberUpdateOf(roomID, memberID string) func() (room.Input, bool) { return func() (room.Input, bool) { @@ -572,7 +573,7 @@ func (g *Gateway) memberUpdateOf(roomID, memberID string) func() (room.Input, bo } } -// 订阅连接断开过:会话和事件流可能漏掉了房间的事件,等待中的请求可能收不到回复。会话和事件流以 1012 断开, +// 订阅连接断开过之后,会话和事件流可能漏掉了房间的事件,等待中的请求可能收不到回复。所以会话和事件流以 1012 断开, // 客户端重连之后以新快照为准。 func (g *Gateway) restart() { g.mu.Lock() @@ -609,7 +610,7 @@ func (g *Gateway) closeAll(reason CloseReason) { } } -// 订阅本实例的频道并刷新存活键:此后其他实例的网关才能把输入转发到这里,owner 也不会认为本实例的网关已经退出。 +// 订阅本实例的频道并刷新存活键。这之后其他实例的网关才能把输入转发到本实例,owner 也不会认为本实例的网关已经退出。 func (g *Gateway) open(ctx context.Context) error { for { attemptCtx, cancel := context.WithTimeout(ctx, clusterTimeout) @@ -713,7 +714,7 @@ func (g *Gateway) joinedRooms() []string { return slices.Collect(maps.Keys(g.sessions)) } -// 订阅连接断开时只通知 lost,不在这里处理:恢复订阅要等这里收到确认。 +// 订阅连接断开时只通知 lost,不在 receive 里处理,因为恢复订阅要等 receive 收到确认。 func (g *Gateway) receive(ctx context.Context, lost chan<- struct{}) { for { d, err := g.bus.Receive(ctx) diff --git a/internal/app/realtime/gateway_test.go b/internal/app/realtime/gateway_test.go index 9e6a2f9..79ecea9 100644 --- a/internal/app/realtime/gateway_test.go +++ b/internal/app/realtime/gateway_test.go @@ -27,7 +27,7 @@ type instance struct { gateway *Gateway } -// 测试中的会话和事件流大多没有真实的票据,核对凭证时把它们都当作仍然有效;核对本身的测试用 porttest.Store 中的票据。 +// 测试中的会话和事件流大多没有真实的票据,核对凭证时把它们都当作仍然有效。核对本身的测试用 porttest.Store 中的票据。 type unrevoked struct{} func (unrevoked) ExistingTickets(_ context.Context, tokenHashes [][]byte) ([][]byte, error) { @@ -108,7 +108,7 @@ func newGatewayFixture(t *testing.T, snapshots port.RoomSnapshots, store *portte return &gatewayFixture{instance: startInstance(t, redis, "a", snapshots, store, songs), store: store, redis: redis, songs: songs, quota: q} } -// quota 的点歌额度可以耗尽,耗尽之后要等一分钟;搜索额度总能取到。 +// quota 的点歌额度可以耗尽,耗尽之后要等一分钟。搜索额度总能取到。 type quota struct { exhausted atomic.Bool } @@ -318,10 +318,10 @@ func TestSessionLeavingDuringRejoinIsNotKeptOnline(t *testing.T) { _, _ = f.gateway.Do(t.Context(), id, alice, room.SetPlayMode{Mode: room.Random}) store.fail.Store(false) synctest.Wait() - // porttest 恢复出的正在播放的歌没有时长:开播时间过去之后再接管,恢复时这首歌已经放完,第一个 effect 是 Persist。 + // porttest 恢复出的正在播放的歌没有时长。开播时间过去之后再接管,恢复时这首歌已经放完,所以第一个 effect 是 Persist。 time.Sleep(time.Second) - // 恢复时的 Persist 卡住,网关重新加入的第一个 Join 等待房间收下;两个会话在此期间先后离开。 + // 恢复时的 Persist 卡住,网关重新加入的第一个 Join 等待房间收下。两个会话在此期间先后离开。 hold := make(chan struct{}) store.setHold(hold) go func() { _, _ = f.gateway.Do(context.Background(), id, alice, room.Sync{}) }() diff --git a/internal/app/realtime/host.go b/internal/app/realtime/host.go index 4a83409..465224c 100644 --- a/internal/app/realtime/host.go +++ b/internal/app/realtime/host.go @@ -25,7 +25,7 @@ const ( reconcileJitter = time.Second leaseTTL = 15 * time.Second renewInterval = 5 * time.Second - // 距上次续约成功超过这个时长就停止处理房间的输入。它早于租约过期,时钟正常时同一时刻只有一个 owner 在处理房间的输入。 + // 距上次续约成功超过这个时长就停止处理房间的输入。ownerDeadline 早于租约过期,所以时钟正常时,同一时刻只有一个 owner 在处理房间的输入。 ownerDeadline = 10 * time.Second // 租约、存活键和实例间消息单次调用的期限,不能超过续约的间隔。 clusterTimeout = 5 * time.Second @@ -59,8 +59,8 @@ type Host struct { owned atomic.Int64 leasesLost atomic.Uint64 - // 上一条接管房间失败的 WARN 的时刻,接管成功之后清空。存储不可用时,每次对账都会为每个没有 owner 的房间尝试接管,且每次都失败: - // takeoverLogRepeat 内只记一条 WARN,其余记 DEBUG,日志量不随房间数和对账次数增长。 + // 上一条接管房间失败的 WARN 的时刻,接管成功之后清空。存储不可用时,每次对账都会为每个没有 owner 的房间尝试接管,且每次都失败。 + // 所以 takeoverLogRepeat 内只记一条 WARN,其余记 DEBUG,这样日志量不随房间数和对账次数增长。 takeoverWarned atomic.Pointer[time.Time] mu sync.Mutex @@ -103,7 +103,7 @@ type hostedRoom struct { cancel context.CancelFunc // 上次续约成功的时刻加上 ownerDeadline,Unix 纳秒。 deadline atomic.Int64 - // 租约已经不属于本实例,或者无法确认还属于本实例:不再执行任何 effect,也不释放租约。 + // 租约已经不属于本实例,或者无法确认还属于本实例。lost 为真时不再执行任何 effect,也不释放租约。 lost atomic.Bool releasing atomic.Bool @@ -138,7 +138,7 @@ func newHost(snapshots port.RoomSnapshots, directory port.RoomDirectory, listens } } -// Start 之后才接管房间:数据库迁移到二进制要求的版本、并且订阅了本实例的频道之前,房间不能运行。 +// Start 之后才接管房间,因为数据库迁移到二进制要求的版本、并且订阅了本实例的频道之前,房间不能运行。 func (h *Host) Start() { h.mu.Lock() defer h.mu.Unlock() @@ -147,7 +147,7 @@ func (h *Host) Start() { } } -// 释放全部房间,在下一次 Start 之前不再接管房间:订阅连接断开过,发给这些房间的输入可能已经丢失。 +// 释放全部房间,在下一次 Start 之前不再接管房间,因为订阅连接断开过,发给这些房间的输入可能已经丢失。 func (h *Host) pause() { h.mu.Lock() defer h.mu.Unlock() @@ -187,7 +187,7 @@ func (h *Host) LeasesLost() uint64 { return h.leasesLost.Load() } -// 每个未关闭的房间都要有 owner:常驻房间没有会话时时间线照常推进,临时房间无人 5 分钟后关闭。 +// 每个未关闭的房间都要有 owner,因为常驻房间没有会话时时间线照常推进,临时房间无人 5 分钟后关闭。 func (h *Host) Reconcile(ctx context.Context) { for { h.checkGateways(ctx) @@ -270,7 +270,7 @@ func (h *Host) checkGateways(ctx context.Context) { } for _, g := range expired { // 网关可能刚刚恢复了存活键,正在为会话重新 Join。Leave 排在重新加入之后时,会话会一直不在在线列表中, - // 所以发 Leave 之前再查一次,缩小这个窗口。窗口无法消除:复查返回之后网关仍可能恢复。 + // 所以发 Leave 之前再查一次,缩小这个窗口。窗口无法消除,因为复查返回之后网关仍可能恢复。 recheckCtx, cancel := context.WithTimeout(ctx, clusterTimeout) still, err := h.liveness.Expired(recheckCtx, []string{g}) cancel() @@ -311,10 +311,10 @@ func (h *Host) ensure(id string) *hostedRoom { return r } -// takeover 为假时,房间不在本实例运行就丢弃输入:重新接管后的 Room +// takeover 为假时,房间不在本实例运行就丢弃输入,因为重新接管后的 Room // 从落盘的快照恢复,在线列表由网关重新加入会话来重建。 func (h *Host) send(ctx context.Context, roomID string, e envelope, takeover bool) error { - // 调用方已经放弃的输入不再交给房间:下面两个 select 的多个分支同时就绪时,Go 随机选择其一,仅靠过期的 ctx 无法阻止输入被交出。 + // 调用方已经放弃的输入不再交给房间。下面两个 select 的多个分支同时就绪时,Go 随机选择其一,所以仅靠过期的 ctx 无法阻止输入被交出。 if ctx.Err() != nil { return context.Cause(ctx) } @@ -352,7 +352,7 @@ func (h *Host) send(ctx context.Context, roomID string, e envelope, takeover boo } } -// 在接收消息的 goroutine 上调用,不能等房间收下输入: +// 在接收消息的 goroutine 上调用,不能等房间收下输入。 // 同一房间的输入按到达的顺序排队,由这个房间的转发 goroutine 依次交给房间。 func (h *Host) deliver(ctx context.Context, in port.RoomInput) { h.mu.Lock() @@ -392,7 +392,7 @@ func (h *Host) forward(roomID string, queue *[]forwardedInput) { } } -// 网关最多等待回复 ownerReplyTimeout,超过这个时间的请求不必再交给房间。期限从请求到达本实例时算起: +// 网关最多等待回复 ownerReplyTimeout,超过这个时间的请求不必再交给房间。期限从请求到达本实例时算起, // 排在忙碌房间后面的请求不能因为轮到得晚而多得一份期限。会话的输入不设期限,与本实例的网关一致。 func forwardContext(received context.Context, id room.RequestID, arrived time.Time) (context.Context, context.CancelFunc) { if id == "" { @@ -416,7 +416,7 @@ func (h *Host) run(r *hostedRoom) { if !h.acquire(r) { return } - // 续约从取得租约时开始,不等接管结束:接管最多耗时 storeTimeout,而期限从取得租约时算起,等接管结束再续约会超过期限。 + // 续约从取得租约时开始,不等接管结束。接管最多耗时 storeTimeout,而期限从取得租约时算起,等接管结束再续约就会超过期限。 keepCtx, stopKeeping := context.WithCancel(context.Background()) r.stopKeeping = stopKeeping h.work.Go(func() { h.keep(keepCtx, r) }) @@ -491,7 +491,7 @@ func (h *Host) acquire(r *hostedRoom) bool { return true } -// 续约不经过房间的 goroutine:房间正在落盘时也要按时续约。 +// 续约不经过房间的 goroutine,因为房间正在落盘时也要按时续约。 func (h *Host) keep(ctx context.Context, r *hostedRoom) { ticker := time.NewTicker(renewInterval) defer ticker.Stop() @@ -550,8 +550,8 @@ func (h *Host) finish(r *hostedRoom) { if r.room != nil { h.owned.Add(-1) } - // 先删除租约,再发出释放通知:收到通知的网关接管房间时,租约已经空出来了。因出错而放弃房间时不发通知: - // 否则落盘一直失败时,网关收到通知就接管、接管之后又落盘失败,会反复接管同一个房间。 + // 先删除租约,再发出释放通知,这样收到通知的网关接管房间时,租约已经空出来了。因出错而放弃房间时不发通知, + // 否则落盘一直失败时,网关收到通知就接管,接管之后又落盘失败,会反复接管同一个房间。 if r.acquired && !r.lost.Load() { ctx, cancel := context.WithTimeout(context.WithoutCancel(r.ctx), clusterTimeout) if err := h.leases.Release(ctx, r.id); err != nil { @@ -572,8 +572,8 @@ func (h *Host) finish(r *hostedRoom) { close(r.done) } -// 调用都带着 ctx 的值,但不随它取消。返回 false 表示这次运行结束:落盘失败、租约不再属于本实例或者消息发布失败时放弃房间, -// 不再执行剩下的 effect,request 还没有回复时让它立即失败;输出 Close 之后丢弃 Room。 +// 调用都带着 ctx 的值,但不随 ctx 取消。返回 false 表示这次运行结束,分两种情况。落盘失败、租约不再属于本实例或者消息发布失败时放弃房间, +// 不再执行剩下的 effect,request 还没有回复时让 request 立即失败。输出 Close 之后丢弃 Room。 func (h *Host) execute(ctx context.Context, r *hostedRoom, effects []room.Effect, request room.RequestID) (ok bool) { replied := false defer func() { @@ -629,8 +629,8 @@ func (h *Host) execute(ctx context.Context, r *hostedRoom, effects []room.Effect return !r.closed } -// 在线人数在广播在线列表之前写入:看到在线列表变化的客户端,在房间列表中看到的也是新的人数。 -// 写入失败只影响房间列表,不放弃房间:真的连不上时续约也会失败,房间随之停止。 +// 在线人数在广播在线列表之前写入,这样看到在线列表变化的客户端,在房间列表中看到的也是新的人数。 +// 写入失败只影响房间列表,所以不放弃房间。真的连不上时续约也会失败,房间随之停止。 func (h *Host) setOnline(ctx context.Context, r *hostedRoom, n int) bool { writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), clusterTimeout) ok, err := h.leases.SetOnline(writeCtx, r.id, n) @@ -654,7 +654,7 @@ func (h *Host) abandon(ctx context.Context, r *hostedRoom, msg string, err error h.logger.ErrorContext(ctx, msg, "error", err) } -// 房间的消息发布失败时,订阅的网关可能漏掉了它,所以放弃房间:下一个 owner 从落盘的快照恢复房间,各网关重新加入会话。 +// 房间的消息发布失败时,订阅的网关可能漏掉了这条消息,所以放弃房间。下一个 owner 从落盘的快照恢复房间,各网关重新加入会话。 func (h *Host) publish(ctx context.Context, r *hostedRoom, m port.RoomMessage) bool { publishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), clusterTimeout) defer cancel() @@ -665,7 +665,7 @@ func (h *Host) publish(ctx context.Context, r *hostedRoom, m port.RoomMessage) b return true } -// 同一批中的 RecordPlay 都排在 Persist 之后,与它在同一个事务中写入。 +// 同一批中的 RecordPlay 都排在 Persist 之后,与 Persist 在同一个事务中写入。 func plays(effects []room.Effect) []room.RecordPlay { var out []room.RecordPlay for _, e := range effects { @@ -676,14 +676,14 @@ func plays(effects []room.Effect) []room.RecordPlay { return out } -// 落盘不随 ctx 取消:释放房间时,正在处理的输入要处理完。 +// 落盘不随 ctx 取消,因为释放房间时,正在处理的输入要处理完。 func (h *Host) persist(ctx context.Context, r *hostedRoom, s room.Snapshot, plays []room.RecordPlay) error { ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), storeTimeout) defer cancel() return h.snapshots.SaveRoom(ctx, r.term, s, plays) } -// 播放历史不是房间的状态:写入失败不放弃房间。与落盘一样不随 ctx 取消。 +// 播放历史不是房间的状态,所以写入失败不放弃房间。写入和落盘一样,不随 ctx 取消。 func (h *Host) recordListen(ctx context.Context, l room.RecordListen) { writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), storeTimeout) defer cancel() @@ -775,7 +775,7 @@ func (r *hostedRoom) track(in room.Input, gateway, self string) { } } -// 请求和成员的 ID 取自输入本身:其他实例转发来的输入只带着追踪上下文。 +// 请求和成员的 ID 取自输入本身,因为其他实例转发来的输入只带着追踪上下文。 func (r *hostedRoom) inputContext(e envelope) context.Context { ctx := r.ctx if e.ctx != nil { diff --git a/internal/app/realtime/host_test.go b/internal/app/realtime/host_test.go index 519187c..4a71195 100644 --- a/internal/app/realtime/host_test.go +++ b/internal/app/realtime/host_test.go @@ -66,7 +66,7 @@ type songs struct { preparer *porttest.Preparer } -// 只有网关点歌(Resolve)要按房间预扣点歌额度,用到房间目录和额度;不点歌的测试用 newSongs。 +// 只有网关点歌(Resolve)要按房间预扣点歌额度,所以要用到房间目录和额度。不点歌的测试用 newSongs。 func newSongs() *songs { return newPickingSongs(nil, nil) } @@ -225,7 +225,7 @@ func TestEffectsRunInOrder(t *testing.T) { f.send(t, id, room.Join{Session: "s1", Member: alice}) f.send(t, id, command("r1", alice, room.Enqueue{Track: sunny, Meta: sunnyM})) - // 准备的结果在下一批处理:点歌的回复先到,开播在后。 + // 准备的结果在下一批处理,所以点歌的回复先到,开播在后。 checkMessages(t, f.spy.take(), "started 1", "1/1 room.MembersChanged", @@ -411,7 +411,7 @@ func TestListenFailureKeepsRoomRunning(t *testing.T) { }) } -// 每次 Restore 的 token 都从 1 开始:放弃房间时如果不停下计时器,它会提前关闭重新接管后的房间。 +// 每次 Restore 的 token 都从 1 开始。放弃房间时如果不停下计时器,旧计时器会提前关闭重新接管后的房间。 func TestTimersOfAbandonedRoomAreDiscarded(t *testing.T) { synctest.Test(t, func(t *testing.T) { store := &failingStore{Store: porttest.New()} @@ -439,7 +439,7 @@ func TestTimersOfAbandonedRoomAreDiscarded(t *testing.T) { }) } -// Prepare 不理会 ctx:检验的是宿主自己丢弃旧运行的结果,而不是靠端口的实现按 ctx 提前返回。 +// Prepare 不理会 ctx,因为要检验的是宿主自己丢弃旧运行的结果,而不是靠端口的实现按 ctx 提前返回。 type gatedPreparer struct { calls atomic.Int32 stale, fresh chan struct{} @@ -739,7 +739,7 @@ func TestLostLeaseStopsRoomImmediately(t *testing.T) { f.send(t, id, room.Join{Session: "s1", Member: alice}) f.spy.take() - // 落盘卡住时租约丢失:落盘返回之后,本批剩下的事件和回复都不再发出。 + // 落盘卡住时租约丢失,所以落盘返回之后,本批剩下的事件和回复都不再发出。 hold := make(chan struct{}) store.setHold(hold) go func() { @@ -1048,7 +1048,7 @@ func TestForwardedRequestsExpireWhileQueued(t *testing.T) { f.host.deliver(context.Background(), port.RoomInput{Room: id, Gateway: "b", Input: in, Takeover: true}) } - // r1 卡在落盘。r2 在期限到达时被拒绝,r3 也一样:它同样是 10 秒之前到达的,轮到它时期限已经过了。 + // r1 卡在落盘。r2 在期限到达时被拒绝,r3 也一样,因为 r3 同样是 10 秒之前到达的,轮到它时期限已经过了。 time.Sleep(ownerReplyTimeout) synctest.Wait() checkMessages(t, f.spy.take(), "started 1", "rejected b/r2", "rejected b/r3") @@ -1059,7 +1059,7 @@ func TestForwardedRequestsExpireWhileQueued(t *testing.T) { }) } -// send 的两个 select 在多个分支同时就绪时随机选择一个,所以要多试几次:漏掉 ctx 检查时,几乎必然有一次会把输入交出去。 +// send 的两个 select 在多个分支同时就绪时随机选择一个,所以要多试几次。漏掉 ctx 检查时,几乎必然有一次会把输入交出去。 func TestSendIgnoresInputOfFinishedCaller(t *testing.T) { synctest.Test(t, func(t *testing.T) { store := porttest.New() diff --git a/internal/app/realtime/observer.go b/internal/app/realtime/observer.go index 4f3fc44..0e6a64c 100644 --- a/internal/app/realtime/observer.go +++ b/internal/app/realtime/observer.go @@ -9,29 +9,29 @@ import ( "github.com/dihemoe/alisten/internal/domain/room" ) -// 按本实例计数:限制的是每个实例为事件流占用的资源。 +// 按本实例计数,因为限制的是每个实例为事件流占用的资源。 const observerLimit = 5 var ErrTooManyObservers = errors.New("这个房间的事件流超过 5 条,请先关闭不用的事件流") -// Observer 是房间的事件流:先收到房间的快照,之后是房间的事件;房间开始新的一次运行时事件重新编号,再收到一份新的快照。 +// Observer 是房间的事件流。事件流先收到房间的快照,之后是房间的事件。房间开始新的一次运行时,事件重新编号,事件流会再收到一份新的快照。 type Observer struct { *sendQueue room string link *roomLink member member.Member - // 凭证签发给的成员会话的 token 哈希,API Key 没有会话,为空。 + // 凭证属于一个成员会话,memberSession 是这个会话的 token 哈希。API Key 没有会话,这时为空。 memberSession string bot bool credential string - // 由 sendQueue 的 mu 保护:最近一次快照的任期,和收到过的 owner 变更通知中最大的任期。 + // term 是最近一次快照的任期,latest 是收到过的 owner 变更通知中最大的任期。两者都由 sendQueue 的 mu 保护。 term, latest uint32 } -// Observe 订阅房间的事件但不加入房间:事件流不出现在在线列表中,不计入投票切歌所需的人数,也不让 AutoDJ 认为房间里有人。 -// m 是凭证的持有者,memberSession 是凭证签发给的成员会话,bot 表示凭证是常驻房间的 API Key,credential 是凭证在存储中的哈希。 -// 名额用完时,同一凭证打开的最早的事件流以 CloseReplaced 关闭,名额让给这一条;没有同一凭证的事件流时返回 ErrTooManyObservers。 +// Observe 订阅房间的事件但不加入房间,所以事件流不出现在在线列表中,不计入投票切歌所需的人数,也不让 AutoDJ 认为房间里有人。 +// m 是凭证的持有者,memberSession 是凭证所属的成员会话,bot 表示凭证是常驻房间的 API Key,credential 是凭证在存储中的哈希。 +// 名额用完时,同一凭证打开的最早的事件流以 CloseReplaced 关闭,名额让给这一条。没有同一凭证的事件流时返回 ErrTooManyObservers。 // 返回时快照已经放进队列。结束时必须调用一次 Unobserve。 func (g *Gateway) Observe(ctx context.Context, roomID string, m member.Member, memberSession []byte, bot bool, credential string) (*Observer, error) { link, err := g.hold(ctx, roomID) @@ -64,7 +64,7 @@ func (g *Gateway) Unobserve(o *Observer) { g.unhold(o.room, o.link, false) } -// 关闭被替换的事件流和占用它的名额在同一次加锁中完成:名额不会超出,同时到达的事件流也不会替换同一条。 +// 关闭被替换的事件流和占用它的名额在同一次加锁中完成,这样名额不会超出,同时到达的事件流也不会替换同一条。 func (g *Gateway) watch(o *Observer) error { g.mu.Lock() defer g.mu.Unlock() @@ -85,7 +85,7 @@ func (g *Gateway) watch(o *Observer) error { return nil } -// 拿到的快照早于收到过的最新一次运行时重新获取。 +// 拿到的快照早于收到过的最新一次运行时,重新获取。 func (g *Gateway) sync(ctx context.Context, o *Observer) error { for { id := g.newRequestID() @@ -109,8 +109,8 @@ func (g *Gateway) resync(o *Observer) { } } -// 在接收消息的 goroutine 上按房间频道的顺序调用:此后房间的事件才发给这个事件流,快照之前的事件都已包含在快照中。 -// 返回 false 表示这份快照已经过时:之后又收到了更新一次运行的 owner 变更通知,从它接着发事件,编号就对不上。 +// 在接收消息的 goroutine 上按房间频道的顺序调用。此后房间的事件才发给这个事件流,快照之前的事件都已包含在快照中。 +// 返回 false 表示这份快照已经过时,因为之后又收到了更新一次运行的 owner 变更通知,从这份快照接着发事件,编号就对不上。 func (o *Observer) snapshot(v room.View) bool { o.mu.Lock() defer o.mu.Unlock() @@ -122,7 +122,7 @@ func (o *Observer) snapshot(v room.View) bool { return true } -// 返回 true 表示要重新获取快照,在此之前不再发事件。正在获取快照时只记下任期,由获取快照的一方判断拿到的快照是否过时。 +// 返回 true 表示要重新获取快照,在此之前不再发事件。正在获取快照时只记下任期,拿到的快照是否过时由 snapshot 判断。 func (o *Observer) ownerChanged(term uint32) bool { o.mu.Lock() defer o.mu.Unlock() diff --git a/internal/app/realtime/observer_test.go b/internal/app/realtime/observer_test.go index 4ecafae..ecb3b5e 100644 --- a/internal/app/realtime/observer_test.go +++ b/internal/app/realtime/observer_test.go @@ -331,7 +331,7 @@ func (b contextBus) PublishSessionsRevoked(ctx context.Context, sessionTokenHash return b.Bus.PublishSessionsRevoked(ctx, sessionTokenHashes) } -// 通知在凭证作废之后发出:发起请求的客户端已经断开时照样发出,发不出去时只记日志。 +// 通知在凭证作废之后发出。发起请求的客户端已经断开时照样发出,发不出去时只记日志。 func TestRevocationNoticesAreBestEffort(t *testing.T) { redis := porttest.NewRedis() node, listener := redis.Node("a"), redis.Node("b") @@ -395,7 +395,7 @@ func TestObserverGetsNewSnapshotWhenRunChanges(t *testing.T) { t.Fatalf("落盘失败时的错误 = %v,期望 ErrUnavailable", err) } store.fail.Store(false) - // 放弃房间的那次运行在自己的 goroutine 里收尾,收尾完之前到达的请求会得到 ErrUnavailable;等它收尾完,Sync 才会重新接管房间。 + // 放弃房间的那次运行在自己的 goroutine 里收尾,收尾完之前到达的请求会得到 ErrUnavailable。等这次运行收尾完,Sync 才会重新接管房间。 synctest.Wait() if _, err := f.gateway.Do(t.Context(), id, alice, room.Sync{}); err != nil { t.Fatal(err) @@ -422,7 +422,7 @@ func (b *heldEmits) PublishRoom(ctx context.Context, roomID string, m port.RoomM return b.Bus.PublishRoom(ctx, roomID, m) } -// a 的租约已经丢失,但它还没有发现:排在它那里的快照请求在 b 接管、发出变更通知之后才得到回复,这份快照已经过时。 +// a 的租约已经丢失,但 a 还没有发现。排在 a 那里的快照请求,在 b 接管、发出变更通知之后才得到回复,所以这份快照已经过时。 func TestObserverSkipsSnapshotOfEarlierRun(t *testing.T) { synctest.Test(t, func(t *testing.T) { store := porttest.New() diff --git a/internal/app/realtime/session.go b/internal/app/realtime/session.go index 1727f11..34242f4 100644 --- a/internal/app/realtime/session.go +++ b/internal/app/realtime/session.go @@ -21,7 +21,7 @@ const ( CloseRevoked CloseRoomGone CloseOverflow - // 只用于事件流:名额用完时让给同一凭证新打开的事件流。 + // 只用于事件流,名额用完时让给同一凭证新打开的事件流。 CloseReplaced ) @@ -34,7 +34,7 @@ type ( View room.View } - // 事件流收到的房间快照:总是第一条消息,房间开始新的一次运行之后再收到一份。 + // 事件流收到的房间快照。Snapshot 总是第一条消息,房间开始新的一次运行之后再收到一份。 Snapshot struct { View room.View } @@ -83,7 +83,7 @@ type Session struct { id room.SessionID room string link *roomLink - // 连接所用的票据签发给的成员会话的 token 哈希。 + // 连接所用的票据属于一个成员会话,memberSession 是这个会话的 token 哈希。 memberSession string credential string // 在发出 Leave 之前设置。重新加入的 Join 可能排在 Leave 之后,网关据此补发 Leave。 @@ -112,7 +112,7 @@ func (s *Session) Room() string { return s.room } -// Context 在关闭时取消。会话向客户端写消息时以它为期限:客户端不再读取时,关闭会话也要能中止阻塞的写入。 +// Context 在关闭时取消。会话向客户端写消息时以 Context 为期限,因为客户端不再读取时,关闭会话也要能中止阻塞的写入。 func (q *sendQueue) Context() context.Context { return q.ctx } @@ -170,7 +170,7 @@ func (s *Session) reply(tag any, res room.Result, err error) { s.enqueue(Reply{Tag: tag, Result: res, Err: err}) } -// 在接收消息的 goroutine 上按房间频道的顺序调用:此后房间的事件才发给这个会话,快照之前的事件都已包含在快照中。 +// 在接收消息的 goroutine 上按房间频道的顺序调用。此后房间的事件才发给这个会话,快照之前的事件都已包含在快照中。 func (s *Session) welcome(v room.View) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/bootstrap/clock.go b/internal/bootstrap/clock.go index 861ffd5..f4f049a 100644 --- a/internal/bootstrap/clock.go +++ b/internal/bootstrap/clock.go @@ -13,7 +13,7 @@ const ( ) // 估算本实例与 Redis 服务器的时钟偏差,两个实例之间的时钟差就是两者偏差之差。 -// 估算失败时保留上一次的值:连不上 Redis 已经反映在就绪检查中。 +// 估算失败时保留上一次的值,因为连不上 Redis 已经反映在就绪检查中。 func watchClock(ctx context.Context, measure func(context.Context) (time.Duration, error), offset prometheus.Gauge) { ticker := time.NewTicker(clockInterval) defer ticker.Stop() diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index bf2acf5..4e1a342 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -47,7 +47,7 @@ func loadServeConfig() (serveConfig, error) { return serveConfig{}, err } publicURL := os.Getenv("ALISTEN_PUBLIC_URL") - // 示例配置在 ALISTEN_DOMAIN 为空时生成 https://,它与空值一样使用本地默认地址。 + // 示例配置在 ALISTEN_DOMAIN 为空时会生成 https://,这个值与空值一样使用本地默认地址。 if publicURL == "" || publicURL == "https://" { publicURL = "http://localhost:9876" } @@ -95,13 +95,14 @@ func loadServeConfig() (serveConfig, error) { storage: objects, storageURL: storageURL, tracing: tracing, - // 与后台显示给管理员、要他填进 OAuth App 的回调地址相同。 + // 与后台显示给管理员的回调地址相同,管理员要把这个地址填进 OAuth App。 github: github.Config{RedirectURL: origin + api.GitHubCallbackPath, Endpoint: github.GitHubCom}, }, nil } // 浏览器发送的 Origin 中,scheme 和主机名都是小写,默认端口省略不写,端口不带前导零,国际化域名写成 punycode, -// IP 地址写成规范形式。浏览器不会发出的写法在启动时就报错:否则带 Origin 的请求全部被拒绝。 +// IP 地址写成规范形式。publicOrigin 只保留 scheme、主机名和端口,并把大小写和默认端口改成浏览器的写法。 +// 端口、国际化域名或 IP 地址的写法与浏览器不同时,启动时就报错,否则带 Origin 的请求全部被拒绝。 func publicOrigin(value string) (string, error) { if value == "" { return "", errors.New("缺少 ALISTEN_PUBLIC_URL") @@ -151,7 +152,7 @@ func canonicalPort(port string) bool { var numericLabel = regexp.MustCompile(`^(?:[0-9]+|0[xX][0-9a-fA-F]*)$`) -// 方括号中的主机是 IPv6 地址:浏览器把它写成小写并压缩连续的零,不带 zone,IPv4 映射地址也写成十六进制。 +// 方括号中的主机是 IPv6 地址。浏览器把 IPv6 地址写成小写并压缩连续的零,不带 zone,IPv4 映射地址也写成十六进制。 // 最后一个标签是数字的主机名,浏览器按 IPv4 解析(127.1、0x7f000001、010.0.0.1 都能解析)之后写成点分十进制, // 所以只有 netip 接受的写法(四段十进制,没有前导零)才与浏览器发送的一致。其他主机名不是 IP 地址,原样发送。 func canonicalHost(host string, bracketed bool) bool { @@ -217,9 +218,9 @@ func checkHTTPURL(name, value string) error { return nil } -// 导出器自己读取这两个变量,地址有误时它并不报错(无法解析时只记一条日志,改为发往本机),所以启动时在这里检查格式。 -// 设置了其中任何一个就导出。OTEL_EXPORTER_OTLP_ENDPOINT 是基地址,导出器在它的路径后加上 /v1/traces; -// OTEL_EXPORTER_OTLP_TRACES_ENDPOINT 是完整的地址,优先于前者。 +// 导出器自己读取这两个变量,地址有误时导出器并不报错(无法解析时只记一条日志,改为发往本机),所以启动时在这里检查格式。 +// 设置了其中任何一个就导出。OTEL_EXPORTER_OTLP_ENDPOINT 是基地址,导出器在它的路径后加上 /v1/traces。 +// OTEL_EXPORTER_OTLP_TRACES_ENDPOINT 是完整的地址,优先于 OTEL_EXPORTER_OTLP_ENDPOINT。 func loadTracing() (bool, error) { endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") tracesEndpoint := os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") diff --git a/internal/bootstrap/contract_test.go b/internal/bootstrap/contract_test.go index 4c1cecf..0786a5a 100644 --- a/internal/bootstrap/contract_test.go +++ b/internal/bootstrap/contract_test.go @@ -68,7 +68,7 @@ func TestContract(t *testing.T) { } } -// 整个包共用一个 PostgreSQL 和一个 Valkey 容器:如果每个测试各起一组容器,一次 go test 要在宿主机上创建、删除几十块虚拟网卡, +// 整个包共用一个 PostgreSQL 和一个 Valkey 容器。如果每个测试各起一组容器,一次 go test 要在宿主机上创建、删除几十块虚拟网卡, // 宿主机上的浏览器每次都会当作网络变化而断开连接。 var shared struct { postgresOnce sync.Once @@ -136,7 +136,7 @@ func startValkey(t *testing.T) func(*testing.T) string { } } -// B 站的视频:完整链接和 b23.tv 短链都识别成第 2 P;番剧的链接认得出但不能点。下载到的是平台的原始音频和一张封面。 +// B 站视频的完整链接和 b23.tv 短链都识别成第 2 P。番剧的链接认得出,但不能点。下载到的是平台的原始音频和一张封面。 // 付费视频的下载在 worker 的测试中设为被拒绝。 func contractBilibili() *porttest.Bilibili { b := porttest.NewBilibili() @@ -209,9 +209,9 @@ func (b *logBuffer) String() string { return b.buf.String() } -// 各场景共用一个 Valkey,而 Pub/Sub 不按逻辑库隔离:实例频道靠随机的实例 ID 区分; +// 各场景共用一个 Valkey,而 Pub/Sub 不按逻辑库隔离。实例频道靠随机的实例 ID 区分。 // members 和 tracks.prepared 两个频道靠场景依次运行、每个场景结束时停止服务来隔开。 -// configure 在读取配置之后修改它,用来换上环境变量配置不了的测试替身,比如假的 GitHub 的地址。 +// configure 在读取配置之后修改配置,用来换上环境变量配置不了的测试替身,比如假的 GitHub 的地址。 func startService(t *testing.T, configure ...func(*serveConfig)) service { t.Helper() if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil { @@ -270,12 +270,15 @@ func startService(t *testing.T, configure ...func(*serveConfig)) service { } // 场景依次由以下几节组成: -// - 可选的 env 放在最前面,每行写成 名称=值; -// - request 和 response 成对出现,是一次 HTTP 请求和完整的响应:response 依次是状态码、若干行 名称: 值 的响应头和响应体,204 没有响应体; -// - connect 名称:打开一个 WebSocket 连接,内容是请求头;紧跟 response 时,期望握手被拒绝; -// - send 名称:发送一帧;receive 名称:读下一条消息,与期望的报文比较; -// - closed 名称:连接应以给出的关闭码断开;disconnect 名称:客户端断开连接; -// - stream 名称:打开房间的事件流,内容是请求头;event 名称:读下一个事件,与期望的事件比较,close 之后事件流应结束。 +// - 可选的 env 放在最前面,每行写成 名称=值。 +// - request 和 response 成对出现,是一次 HTTP 请求和完整的响应。response 依次是状态码、若干行 名称: 值 的响应头和响应体,204 没有响应体。 +// - connect 名称:打开一个 WebSocket 连接,内容是请求头。紧跟 response 时,期望握手被拒绝。 +// - send 名称:发送一帧。 +// - receive 名称:读下一条消息,与期望的报文比较。 +// - closed 名称:连接应以给出的关闭码断开。 +// - disconnect 名称:客户端断开连接。 +// - stream 名称:打开房间的事件流,内容是请求头。 +// - event 名称:读下一个事件,与期望的事件比较。close 之后事件流应结束。 func runContract(t *testing.T, sections []txtar.File, databaseURL, redisURL string) { contractEnv(t, databaseURL, redisURL) if len(sections) > 0 && sections[0].Name == "env" { @@ -445,7 +448,7 @@ func dialRejected(t *testing.T, server *httptest.Server, header string) (int, ht return resp.StatusCode, resp.Header, body } -// 消息总会到达,期限只是为了出错时不一直等下去:最长的一次要等《片头》放完,15 秒。 +// 消息总会到达,期限只是为了出错时不一直等下去。最长的一次要等《片头》放完,也就是 15 秒。 const messageWait = 25 * time.Second func receive(t *testing.T, conn *websocket.Conn, name, want string, vars map[string]string) { @@ -506,7 +509,7 @@ func openStream(t *testing.T, server *httptest.Server, header string) *eventStre return &eventStream{body: resp.Body, reader: bufio.NewReader(resp.Body)} } -// 返回下一个事件的各行,跳过心跳之类只有注释的事件;事件流结束时返回 nil。 +// 返回下一个事件的各行,跳过心跳之类只有注释的事件。事件流结束时返回 nil。 func (s *eventStream) next(t *testing.T, name string) []string { t.Helper() timeout := time.AfterFunc(messageWait, func() { _ = s.body.Close() }) @@ -561,7 +564,7 @@ func checkEvent(t *testing.T, s *eventStream, name, want string, vars map[string } } -// 服务端生成的正数时间戳在期望中统一写为 1:字段名以 AtMs 结尾,或者是 serverTimeMs。 +// 服务端生成的正数时间戳在期望中统一写为 1。时间戳指字段名以 AtMs 结尾的字段,以及 serverTimeMs。 func timestamps(v any) any { switch v := v.(type) { case map[string]any: @@ -580,7 +583,7 @@ func timestamps(v any) any { return v } -// $ 加小写字母开头的名字是变量:在响应中第一次出现时记下实际的值,之后出现时必须相同;在请求中替换为记下的值。 +// $ 加小写字母开头的名字是变量。变量在响应中第一次出现时记下实际的值,之后再出现时必须相同。在请求中,变量替换为记下的值。 var variable = regexp.MustCompile(`\$[a-z][A-Za-z0-9]*`) func expand(t *testing.T, text string, vars map[string]string) string { @@ -627,7 +630,7 @@ func send(t *testing.T, server *httptest.Server, request string) (string, int, h var headerLine = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9-]*): (.*)$`) -// 响应头在响应体之后比较,可以使用响应体中第一次出现的变量。没有列出 Location 时,响应也不能带 Location。 +// 响应头在响应体之后比较,所以可以使用响应体中第一次出现的变量。没有列出 Location 时,响应也不能带 Location。 func checkResponse(t *testing.T, line string, status int, header http.Header, body []byte, want string, vars map[string]string) { t.Helper() lines := strings.Split(strings.TrimSpace(want), "\n") @@ -729,7 +732,7 @@ func match(want, got any, vars map[string]string, path string) error { return nil } -// 不同的变量必须取不同的值:两次签发的 token 或两个房间的 ID 相同时,测试会失败。 +// 不同的变量必须取不同的值。这样两次签发的 token 或两个房间的 ID 相同时,测试会失败。 func bind(name string, got any, vars map[string]string, path string) error { value, ok := got.(string) if !ok || value == "" { diff --git a/internal/bootstrap/database.go b/internal/bootstrap/database.go index c8d15e0..15d5a3e 100644 --- a/internal/bootstrap/database.go +++ b/internal/bootstrap/database.go @@ -29,7 +29,7 @@ func loadDatabaseConfig() (*pgxpool.Config, error) { if err != nil { return nil, fmt.Errorf("无法由 DATABASE_* 和 PG* 环境变量组成 PostgreSQL 的连接配置:%w", err) } - // 密码不放进连接串:pgx 的 ConnString() 原样返回连接串,解析错误中的脱敏也只是尽力而为。 + // 密码不放进连接串,因为 pgx 的 ConnString() 会原样返回连接串,解析错误中的脱敏也只是尽力而为。 cfg.ConnConfig.Password = os.Getenv("DATABASE_PASSWORD") return cfg, nil } diff --git a/internal/bootstrap/github_test.go b/internal/bootstrap/github_test.go index 0099a7c..3d7440d 100644 --- a/internal/bootstrap/github_test.go +++ b/internal/bootstrap/github_test.go @@ -18,9 +18,9 @@ import ( "github.com/dihemoe/alisten/internal/adapter/out/github" ) -// 假的 GitHub:有 client-1 和 client-2 两个 OAuth App,Client Secret 分别是 secret-1 和 secret-2。用户在授权页同意时签发的授权码 -// code- 绑定授权地址中的 client_id 和 code_challenge,换取令牌时 client_id 和 code_verifier 必须与它们对上;授权码换到令牌 -// token-,令牌 token- 是用户 n。 +// 假的 GitHub 有 client-1 和 client-2 两个 OAuth App,Client Secret 分别是 secret-1 和 secret-2。用户在授权页同意时签发授权码 +// code-,授权码绑定授权地址中的 client_id 和 code_challenge,换取令牌时的 client_id 和 code_verifier 必须和绑定的值对上。 +// 授权码 code- 换到令牌 token-,令牌 token- 是用户 n。 type fakeGitHub struct { t *testing.T endpoint github.Endpoint @@ -77,7 +77,7 @@ func newFakeGitHub(t *testing.T) *fakeGitHub { return f } -// 模拟用户在授权页同意授权地址的参数为 authorize 的这次授权,GitHub 签发授权码 code。 +// 模拟用户在授权页同意一次授权,GitHub 签发授权码 code。authorize 是这次授权的授权地址的参数。 func (f *fakeGitHub) approve(authorize url.Values, code string) { f.t.Helper() if authorize.Get("code_challenge_method") != "S256" || authorize.Get("code_challenge") == "" { @@ -164,7 +164,7 @@ func (c githubClient) configure(clientID string) { } } -// 回调地址的查询串里有授权码和 state,Client Secret 在换取令牌的请求体和后台的请求体里:它们都不能出现在 span 和日志中。 +// 回调地址的查询串里有授权码和 state,换取令牌的请求体和后台的请求体里有 Client Secret。授权码、state 和 Client Secret 都不能出现在 span 和日志中。 func checkNoGitHubSecrets(t *testing.T, svc service, values ...string) { t.Helper() values = append(values, "secret-1", "secret-2") @@ -252,7 +252,7 @@ func TestGitHubLogin(t *testing.T) { t.Errorf("配置了 GitHub 时站点 = %d %v,期望 githubLogin 为 true", status, body) } - // 首次用 GitHub 登录就是注册:成员和成员 token 都不变。 + // 首次用 GitHub 登录就是注册,成员和成员 token 都不变。 alice := c.guest() status, body := c.finish(alice, c.exchangeCode(alice, "login", nonce, "", "code-42"), nonce) if status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"github":true}`) { @@ -263,7 +263,7 @@ func TestGitHubLogin(t *testing.T) { t.Errorf("注册之后 /api/me = %d %v", status, body) } - // 另一台设备用同一个 GitHub 登录:得到账号的新 token,游客的 token 随即失效。 + // 另一台设备用同一个 GitHub 登录,得到账号的新 token,游客的 token 随即失效。 bob := c.guest() status, body = c.finish(bob, c.exchangeCode(bob, "login", nonce, "", "code-42"), nonce) if status != http.StatusOK || body["memberToken"] == nil || body["member"].(map[string]any)["id"] != aliceID { @@ -292,7 +292,7 @@ func TestGitHubLogin(t *testing.T) { t.Errorf("state 已经作废时用户在 GitHub 上取消 = %q", got) } - // 授权码只能配合发起那次授权时的 verifier 换取令牌:把别人的授权码放进自己发起的回调里,换不到别人的身份。 + // 授权码只能配合发起那次授权时的 verifier 换取令牌。所以把别人的授权码放进自己发起的回调里,换不到别人的身份。 c.github.approve(c.authorize(carol, "login", nonce, ""), "code-7") mallory := c.guest() attack := c.authorize(mallory, "login", nonce, "").Get("state") @@ -304,7 +304,7 @@ func TestGitHubLogin(t *testing.T) { } checkNoGitHubSecrets(t, svc, attack, "code-7") - // 没有密码的账号不能解绑;用 GitHub 确认身份、设置用户名和密码之后可以。 + // 没有密码的账号不能解绑。用 GitHub 确认身份、设置用户名和密码之后就可以解绑。 if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusConflict { t.Errorf("没有密码的账号解绑 = %d %v,期望 409", status, body) } @@ -318,7 +318,7 @@ func TestGitHubLogin(t *testing.T) { t.Errorf("设置密码之后解绑 = %d,期望 204", status) } - // 已注册的账号绑定 GitHub;关闭注册不影响绑定,但没有绑定过的 GitHub 不能再注册。 + // 已注册的账号绑定 GitHub。关闭注册不影响绑定,但没有绑定过的 GitHub 不能再注册。 if status, _ := c.call(http.MethodDelete, "/api/admin/registration", "admin-secret", ""); status != http.StatusNoContent { t.Fatalf("关闭注册 = %d", status) } @@ -333,7 +333,7 @@ func TestGitHubLogin(t *testing.T) { } } -// App 发起授权时带上 client app,回调跳回 App 的自定义 scheme。跳转目标只看发起授权时记下的 client,回调地址里的参数改变不了它。 +// App 发起授权时带上 client app,回调跳回 App 的自定义 scheme。跳转目标只看发起授权时记下的 client,回调地址里的参数改变不了跳转目标。 func TestGitHubAppCallback(t *testing.T) { newDatabase, newRedis := startPostgres(t), startValkey(t) contractEnv(t, newDatabase(t), newRedis(t)) @@ -367,7 +367,7 @@ func TestGitHubAppCallback(t *testing.T) { t.Errorf("App 兑换 = %d %v,期望注册成只绑定了 GitHub 的账号", status, body) } - // 用户在 GitHub 上拒绝时也跳回 App,state 随即作废;作废之后再回调就跳到网页。 + // 用户在 GitHub 上拒绝时也跳回 App,state 随即作废。作废之后再回调就跳到网页。 bob := c.guest() state := c.start(bob, app).Get("state") if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != landing+"error=denied" { @@ -413,7 +413,7 @@ func TestGitHubAppCallback(t *testing.T) { } } -// 只凭会话不能更换登录凭证:绑定和解绑 GitHub 要输入当前密码;只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的 +// 只凭会话不能更换登录凭证。绑定和解绑 GitHub 要输入当前密码。只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的 // GitHub 确认身份,补设之后其他设备全部退出。 func TestGitHubReauthentication(t *testing.T) { newDatabase, newRedis := startPostgres(t), startValkey(t) @@ -501,8 +501,8 @@ func TestGitHubReauthentication(t *testing.T) { } } -// 每次授权和兑换都读后台最新的配置,所有实例立即生效。授权进行中换了 OAuth App,GitHub 不认原来签发的授权码,回调失败; -// 关闭之后,GitHub 相关的接口返回 NOT_FOUND,回调跳到发起授权的客户端,带上 error=failed。 +// 每次授权和兑换都读后台最新的配置,所有实例立即生效。授权进行中换了 OAuth App,GitHub 不认原来签发的授权码,回调失败。 +// 关闭 GitHub 登录之后,GitHub 相关的接口返回 NOT_FOUND,回调跳到发起授权的客户端,带上 error=failed。 func TestGitHubOAuthAppChanges(t *testing.T) { newDatabase, newRedis := startPostgres(t), startValkey(t) contractEnv(t, newDatabase(t), newRedis(t)) diff --git a/internal/bootstrap/httpserver.go b/internal/bootstrap/httpserver.go index 583b22a..5b6ca03 100644 --- a/internal/bootstrap/httpserver.go +++ b/internal/bootstrap/httpserver.go @@ -14,7 +14,7 @@ func newHTTPServer(handler http.Handler, logger *slog.Logger) *http.Server { return &http.Server{ Handler: handler, ReadHeaderTimeout: 10 * time.Second, - // 与请求体的上限相同:房间内的 GET 和 DELETE 请求把参数放在 URL 里。 + // 与请求体的上限相同,因为房间内的 GET 和 DELETE 请求把参数放在 URL 里。 MaxHeaderBytes: 64 << 10, ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), } diff --git a/internal/bootstrap/logging.go b/internal/bootstrap/logging.go index 472c5a8..e1f7343 100644 --- a/internal/bootstrap/logging.go +++ b/internal/bootstrap/logging.go @@ -15,7 +15,7 @@ const redacted = "[REDACTED]" var sensitiveKeyParts = []string{"cookie", "token", "ticket", "password", "secret", "authorization"} -// 不在这里加 component:slog 的 JSON 输出不合并重复字段,component 只能由各组件派生时加一次。 +// 不在这里加 component,因为 slog 的 JSON 输出不合并重复字段。component 只能在各组件派生 logger 时加一次。 func newLogger(w io.Writer, level slog.Level) *slog.Logger { handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ Level: level, @@ -24,7 +24,7 @@ func newLogger(w io.Writer, level slog.Level) *slog.Logger { return slog.New(logctx.NewHandler(spanFields{handler})).With("service", "alisten", "version", version) } -// 只按字段名判断,含有凭证的结构体作为一个字段记录时不会被脱敏。 +// 只按字段名判断,所以含有凭证的结构体作为一个字段记录时不会被脱敏。 func redactSensitive(_ []string, attr slog.Attr) slog.Attr { key := strings.ToLower(attr.Key) for _, part := range sensitiveKeyParts { @@ -35,7 +35,7 @@ func redactSensitive(_ []string, attr slog.Attr) slog.Attr { return attr } -// 只有采样的 span 会导出:没有采样时不写 trace_id 和 span_id,日志才不会引用不存在的追踪。 +// 只有采样的 span 会导出。没有采样时不写 trace_id 和 span_id,这样日志就不会引用不存在的追踪。 type spanFields struct { slog.Handler } diff --git a/internal/bootstrap/main.go b/internal/bootstrap/main.go index 084e68b..8f1071a 100644 --- a/internal/bootstrap/main.go +++ b/internal/bootstrap/main.go @@ -38,7 +38,7 @@ func Main(args []string) int { } logger := base.With("component", name) - // 把标准库 log 包和 OpenTelemetry 的日志接入 slog,第三方库的日志也输出为 JSON。 + // 把标准库 log 包和 OpenTelemetry 的日志接入 slog,这样第三方库的日志也输出为 JSON。 slog.SetDefault(logger) routeOpenTelemetryLogs(base.With("component", "otel")) if err := run(base); err != nil { diff --git a/internal/bootstrap/media_test.go b/internal/bootstrap/media_test.go index 3c0ff9e..e1dae8a 100644 --- a/internal/bootstrap/media_test.go +++ b/internal/bootstrap/media_test.go @@ -107,7 +107,7 @@ func TestRestoredLinksNeedTheirAudio(t *testing.T) { t.Cleanup(pool.Close) rooms := postgres.New(pool) - // 上一个 owner 已经把房间落盘:等待队列中有两个已就绪的链接曲目,只有第二个的音频还在对象存储中。 + // 上一个 owner 已经把房间落盘。等待队列中有两个已就绪的链接曲目,只有第二个的音频还在对象存储中。 hall, err := rooms.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "大厅"}) if err != nil { t.Fatal(err) @@ -158,7 +158,7 @@ func TestRestoredLinksNeedTheirAudio(t *testing.T) { } } -// 准备好的链接曲目进了歌单之后,三处封面地址都由对象存储的公开地址和封面的对象键拼出;换一种大小写写法也能找到它。 +// 准备好的链接曲目进了歌单之后,三处封面地址都由对象存储的公开地址和封面的对象键拼出。换一种大小写写法也能找到这首曲目。 func TestPlaylistLinkTracks(t *testing.T) { contractEnv(t, startPostgres(t)(t), startValkey(t)(t)) svc := startService(t) @@ -253,7 +253,7 @@ func TestPlaylistLinkTracks(t *testing.T) { } } -// 翻过最后一页时总数照常;批量删除时,正在房间里播放的歌不删,不存在的忽略,其余的连同音频一起删掉。 +// 翻过最后一页时总数照常。批量删除时,正在房间里播放的歌不删,不存在的忽略,其余的连同音频一起删掉。 func TestAdminMediaPagesAndRemovals(t *testing.T) { contractEnv(t, startPostgres(t)(t), startValkey(t)(t)) svc := startService(t) diff --git a/internal/bootstrap/metrics_test.go b/internal/bootstrap/metrics_test.go index 78ebb38..ff1db85 100644 --- a/internal/bootstrap/metrics_test.go +++ b/internal/bootstrap/metrics_test.go @@ -15,7 +15,7 @@ import ( "github.com/dihemoe/alisten/internal/domain/room" ) -// 返回指标的值,直方图返回样本数;labels 依次是标签名和值。没有这个指标时测试失败。 +// 返回指标的值,直方图返回样本数。labels 依次是标签名和值。没有这个指标时测试失败。 func metricValue(t *testing.T, metrics prometheus.Gatherer, name string, labels ...string) float64 { t.Helper() families, err := metrics.Gather() diff --git a/internal/bootstrap/propagation_test.go b/internal/bootstrap/propagation_test.go index 913d39d..90bfdc4 100644 --- a/internal/bootstrap/propagation_test.go +++ b/internal/bootstrap/propagation_test.go @@ -14,7 +14,7 @@ import ( "github.com/dihemoe/alisten/internal/domain/track" ) -// 模拟房间已由另一个实例接管:本实例下一次落盘会因任期过期而失败。 +// 模拟房间已由另一个实例接管,这样本实例下一次落盘会因任期过期而失败。 func bumpTerm(t *testing.T, databaseURL, roomID string) { t.Helper() conn, err := pgx.Connect(t.Context(), databaseURL) diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index 23bf849..dd5f82a 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -112,10 +112,10 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc }, nil } -// trace 在外层,恢复 panic 在内层:这样 panic 的请求才会以状态码 500 记成出错的 span,panic 的日志也带有 trace_id 和 span_id。 -// 每个 HTTP 请求开始一条新的 trace:公开端口上客户端带来的追踪上下文不可信,所以不提取。WebSocket 的升级请求和房间的事件流 -// 都一直持续到连接断开,不记 span;WebSocket 连接中的请求各自记 span。不导出追踪(tracing 为假)时不装 otelhttp:即使使用 -// noop 的 provider,它也会让每个请求付出几微秒和几十次分配。 +// trace 在外层,恢复 panic 在内层。这样 panic 的请求才会以状态码 500 记成出错的 span,panic 的日志也带有 trace_id 和 span_id。 +// 每个 HTTP 请求开始一条新的 trace。公开端口上客户端带来的追踪上下文不可信,所以不提取。WebSocket 的升级请求和房间的事件流 +// 都一直持续到连接断开,所以不记 span。WebSocket 连接中的请求各自记 span。不导出追踪(tracing 为假)时不装 otelhttp,因为即使使用 +// noop 的 provider,otelhttp 也会让每个请求付出几微秒和几十次分配。 func publicHandler(next http.Handler, tracing bool, tracer trace.TracerProvider, logger *slog.Logger) http.Handler { next = recoverPanics(next, logger) if !tracing { @@ -138,7 +138,7 @@ func (p public) start() (<-chan struct{}, error) { return p.jobs.Stopped(), nil } -// 房间先于会话停止:会话断开时房间不再处理它们的 Leave,也不再向其余会话广播。 +// 房间先于会话停止。这样会话断开时,房间不再处理这些会话的 Leave,也不再向其余会话广播。 // 某一步失败时后面的步骤照样执行,返回的错误合并了各步的错误。 func (p public) shutdown(ctx context.Context, server *http.Server, logger *slog.Logger) error { var errs []error @@ -159,7 +159,7 @@ func (p public) shutdown(ctx context.Context, server *http.Server, logger *slog. return errors.Join(errs...) } -// 前面的步骤可能已经用完共用的期限,所以 River client 的停止有自己的期限: +// 前面的步骤可能已经用完共用的期限,所以停止 River client 时用单独的期限。 // 运行中的任务如果不交还给 River,就会停在 running 状态,要等 20 分钟之后才由 River 重新调度。 func (p public) stopJobs(ctx context.Context, logger *slog.Logger) error { ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), jobsStopTimeout) diff --git a/internal/bootstrap/readiness.go b/internal/bootstrap/readiness.go index 8eed097..f37cd05 100644 --- a/internal/bootstrap/readiness.go +++ b/internal/bootstrap/readiness.go @@ -30,10 +30,10 @@ func watchReadiness(ctx context.Context, interval time.Duration, check func(cont return } ready.Store(err == nil) - // 不可用的原因变化时也要记录,否则日志中只有最初的原因;原因不变时不重复记录。 + // 不可用的原因变化时也要记录,否则日志中只有最初的原因。原因不变时不重复记录。 switch { case stopping.Load(): - // 开始退出之后实例有意不就绪,依赖检查的结果不记日志。 + // 开始退出之后,实例是有意不就绪的,所以依赖检查的结果不记日志。 case err != nil && (first || last == nil || reason(err) != reason(last)): logger.WarnContext(ctx, "依赖检查未通过,实例未就绪", "error", err) case err == nil && (first || last != nil): diff --git a/internal/bootstrap/readiness_test.go b/internal/bootstrap/readiness_test.go index 4b762e1..6b2fd1e 100644 --- a/internal/bootstrap/readiness_test.go +++ b/internal/bootstrap/readiness_test.go @@ -28,7 +28,7 @@ func readTimeout(port int) error { func TestWatchReadinessFollowsChecks(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) var ready, stopping atomic.Bool - // 每次检查开始时,ready 是上一次检查的结果;检查返回之前它不会变。 + // 每次检查开始时,ready 是上一次检查的结果。检查返回之前,ready 不会变。 previous := make(chan bool) results := make(chan error) check := func(checkCtx context.Context) error { diff --git a/internal/bootstrap/redis.go b/internal/bootstrap/redis.go index d157cd4..1788f56 100644 --- a/internal/bootstrap/redis.go +++ b/internal/bootstrap/redis.go @@ -45,7 +45,7 @@ func pingRedis(ctx context.Context, rdb *goredis.Client) error { return nil } -// Redis 不可用期间,订阅连接每秒重连一次,go-redis 每次都记一条同样的日志:同样的内容一分钟内只记一次。 +// Redis 不可用期间,订阅连接每秒重连一次,go-redis 每次都记一条同样的日志。所以同样的内容一分钟内只记一次。 const redisLogRepeat = time.Minute // go-redis 自己的日志默认以纯文本写到 stderr,接入 slog 之后进程的全部输出才都是 JSON。 diff --git a/internal/bootstrap/serve.go b/internal/bootstrap/serve.go index b371b61..0c6b924 100644 --- a/internal/bootstrap/serve.go +++ b/internal/bootstrap/serve.go @@ -36,8 +36,8 @@ const ( drainDelay = 5 * time.Second // 释放房间、关闭会话和关闭端口共用这段期限。 shutdownTimeout = 17 * time.Second - // River client 的停止有自己的期限:即使前面的步骤用完了共用的期限,运行中的任务也要交还给 River。 - // 三者之和加上导出剩余追踪的 flushTimeout,不能超过 Kubernetes 30 秒的退出宽限期。 + // 停止 River client 时用单独的期限,因为即使前面的步骤用完了共用的期限,运行中的任务也要交还给 River。 + // drainDelay、shutdownTimeout 和 jobsStopTimeout 之和加上导出剩余追踪的 flushTimeout,不能超过 Kubernetes 30 秒的退出宽限期。 jobsStopTimeout = 3 * time.Second ) @@ -51,7 +51,7 @@ func runServe(base *slog.Logger) (err error) { signals, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) defer stopSignals() - // 每次启动都生成新的实例 ID:上一次运行留下的租约和会话不会被当作这一次的。 + // 每次启动都生成新的实例 ID,这样上一次运行留下的租约和会话不会被当作这一次的。 instance := rand.Text() tracer, flush, err := newTracerProvider(signals, cfg.tracing, "alisten-serve", instance) if err != nil { @@ -117,15 +117,15 @@ func runServe(base *slog.Logger) (err error) { return pingRedis(ctx, rdb) }, &dependenciesReady, &stopping, logger) }) - // 数据库迁移到二进制要求的版本之后,才开始执行准备任务、运行房间;realtime.Start 会一直重试,直到订阅了本实例的频道。 + // 数据库迁移到二进制要求的版本之后,才开始执行准备任务、运行房间。realtime.Start 会一直重试,直到订阅了本实例的频道。 running.Go(func() { select { case <-migrated: case <-background.Done(): return } - // 开始退出之后不再启动 River client:shutdown 只停止已经启动的 client, - // 此后才启动的 client 会带着运行中的任务随进程退出,这些任务要等 20 分钟才由 River 重新调度。 + // 开始退出之后不再启动 River client,因为 shutdown 只停止已经启动的 client。 + // shutdown 之后才启动的 client 会带着运行中的任务随进程退出,这些任务要等 20 分钟才由 River 重新调度。 starting.Lock() if stopping.Load() { starting.Unlock() @@ -197,7 +197,7 @@ func runServe(base *slog.Logger) (err error) { starting.Lock() stopping.Store(true) starting.Unlock() - // 出错退出时本实例已经无法正常服务,不必等入口摘除它;照样释放房间,其他实例就不必等租约过期。 + // 出错退出时本实例已经无法正常服务,所以不必等入口摘除本实例。房间照样要释放,这样其他实例就不必等租约过期。 if failure == nil { logger.Info("收到退出信号,等待入口摘除本实例", "drain", drainDelay.String()) time.Sleep(drainDelay) @@ -205,7 +205,7 @@ func runServe(base *slog.Logger) (err error) { shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() - // 释放房间和关闭会话时仍要收发实例间消息,房间运行时最后才停止。 + // 释放房间和关闭会话时仍要收发实例间消息,所以房间运行时最后才停止。 if err := p.shutdown(shutdownCtx, publicServer, logger); err != nil { return errors.Join(failure, err) } diff --git a/internal/bootstrap/shutdown_test.go b/internal/bootstrap/shutdown_test.go index 7948093..6b11a8c 100644 --- a/internal/bootstrap/shutdown_test.go +++ b/internal/bootstrap/shutdown_test.go @@ -21,8 +21,8 @@ import ( "github.com/coder/websocket" ) -// alice 不回应关闭握手,网关要等她最多 5 秒; -// 这段时间里 bob 已经收到 1012,如果会话先于房间关闭,房间此时还在处理命令。 +// alice 不回应关闭握手,网关要等她最多 5 秒。 +// 这段时间里 bob 已经收到 1012。如果会话先于房间关闭,房间这时还在处理命令。 func TestShutdownReleasesRoomsBeforeClosingSessions(t *testing.T) { contractEnv(t, startPostgres(t)(t), startValkey(t)(t)) svc := startService(t) @@ -59,7 +59,7 @@ func TestShutdownReleasesRoomsBeforeClosingSessions(t *testing.T) { } _, _ = join() bob, bobTicket := join() - // bob 加入时 alice 收到的在线列表留在她的连接中不读:她既不读取消息,也不回应关闭握手。 + // bob 加入时 alice 收到的在线列表留在她的连接中不读。alice 既不读取消息,也不回应关闭握手。 var logs bytes.Buffer done := make(chan error, 1) @@ -157,12 +157,12 @@ func (b *lockedBuffer) messages(t *testing.T) []string { return msgs } -// 接受连接但从不应答,span 要到退出时才导出:这样导出剩下的 span 会超时,而不是很快失败。 +// 接受连接但从不应答,span 要到退出时才导出。这样导出剩下的 span 会超时,而不是很快失败。 func unresponsiveCollector(t *testing.T) { t.Helper() release := make(chan struct{}) collector := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-release })) - // Cleanup 按注册的逆序执行:先放行,Close 才不会等待没有返回的请求。 + // Cleanup 按注册的逆序执行,所以先放行请求,Close 才不会等待没有返回的请求。 t.Cleanup(collector.Close) t.Cleanup(func() { close(release) }) t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", collector.URL) @@ -227,7 +227,7 @@ func TestServeExitsGracefullyOnSIGTERM(t *testing.T) { }() eventually(t, "/readyz 返回 200", finished, func() bool { return readyStatus(opsListen) == http.StatusOK }) - // 这个 HTTP 请求记下一个 span,它到退出时才导出。 + // 这个 HTTP 请求记下的 span 到退出时才导出。 if resp, err := (&http.Client{Timeout: 10 * time.Second}).Get("http://" + listen + "/api/rooms"); err != nil { t.Fatal(err) } else { @@ -237,7 +237,7 @@ func TestServeExitsGracefullyOnSIGTERM(t *testing.T) { if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil { t.Fatal(err) } - // 退出开始的日志写出时,就绪标志已经清除;入口摘除之前(5 秒)运维端口仍然可以访问。 + // 退出开始的日志写出时,就绪标志已经清除。在入口摘除之前(5 秒),运维端口仍然可以访问。 eventually(t, "退出开始的日志", finished, func() bool { return slices.Contains(logs.messages(t), "收到退出信号,等待入口摘除本实例") }) @@ -338,7 +338,7 @@ func (p *tcpProxy) disconnect() { } } -// 会话不回应关闭握手,关闭它要等最多 5 秒:PostgreSQL 断开之后、依赖检查停止之前,至少完成一次检查。 +// 会话不回应关闭握手,关闭会话最多要等 5 秒。所以 PostgreSQL 断开之后、依赖检查停止之前,至少会完成一次检查。 func TestServeLogsNoReadinessDuringExit(t *testing.T) { database := startPostgres(t)(t) contractEnv(t, database, startValkey(t)(t)) diff --git a/internal/bootstrap/spans.go b/internal/bootstrap/spans.go index 6461ce0..6746cf0 100644 --- a/internal/bootstrap/spans.go +++ b/internal/bootstrap/spans.go @@ -23,9 +23,9 @@ import ( const scope = "github.com/dihemoe/alisten/internal/bootstrap" -// 外部调用只在已采样的 trace 中记录子 span:续约、心跳、对账和任务轮询等后台调用每隔几秒就有一次,不能各自成为一条 trace。 -// 父 span 已经结束时也照样记录:owner 处理收到的输入、提交准备任务都发生在发起它们的 span 结束之后。 -// 需要计算名字和属性的调用先用它判断,没有采样的后台调用就不必付出这份开销。 +// 外部调用只在已采样的 trace 中记录子 span,因为续约、心跳、对账和任务轮询等后台调用每隔几秒就有一次,不能各自成为一条 trace。 +// 父 span 已经结束时也照样记录,因为 owner 处理收到的输入、提交准备任务都发生在发起它们的 span 结束之后。 +// 需要计算名字和属性的调用先用 inSampledTrace 判断,这样没有采样的后台调用就不必付出这份开销。 func inSampledTrace(ctx context.Context) bool { return trace.SpanContextFromContext(ctx).IsSampled() } @@ -37,8 +37,8 @@ func startChild(ctx context.Context, tracer trace.Tracer, name string, opts ...t return tracer.Start(ctx, name, append(opts, trace.WithSpanKind(trace.SpanKindClient))...) } -// otelriver 提交任务时总是开始 producer span:没有发起它的请求时(接管之后重新准备、AutoDJ),它会自成一条 trace。 -// 这里只在已采样的 trace 中开始它;没有 span 时,任务的元数据中也没有追踪上下文。执行任务的 consumer span 不受影响,照常另开 trace。 +// otelriver 提交任务时总是开始 producer span。没有发起提交的请求时(接管之后重新准备、AutoDJ),producer span 会自成一条 trace。 +// insertsInTraces 只在已采样的 trace 中开始 producer span。没有 span 时,任务的元数据中也没有追踪上下文。执行任务的 consumer span 不受影响,照常另开 trace。 type insertsInTraces struct{ trace.TracerProvider } func (p insertsInTraces) Tracer(name string, opts ...trace.TracerOption) trace.Tracer { @@ -55,7 +55,7 @@ func (t insertTracer) Start(ctx context.Context, name string, opts ...trace.Span return t.Tracer.Start(ctx, name, opts...) } -// span 的状态只记出错,不记错误的文本:上游的错误中可能有带签名的地址,它们只写进日志。 +// span 的状态只记出错,不记错误的文本,因为上游的错误中可能有带签名的地址。错误的文本只写进日志。 func recordError(span trace.Span, err error) error { if err != nil { span.SetStatus(codes.Error, "") @@ -72,7 +72,7 @@ type databaseSpans struct { tracer trace.Tracer } -// 在 ctx 中记下 TraceQueryStart 开始的 span:TraceQueryEnd 只结束这个 span,不能结束 ctx 中原有的 span。 +// 在 ctx 中记下 TraceQueryStart 开始的 span,这样 TraceQueryEnd 只结束这个 span,不会结束 ctx 中原有的 span。 type querySpan struct{} func (d databaseSpans) TraceQueryStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryStartData) context.Context { @@ -112,8 +112,8 @@ func endQuery(ctx context.Context, err error) { } } -// sqlc 和 River 的查询都以"-- name: 查询名 :类型"开头,span 以查询名命名;其他语句取第一个词,比如 BEGIN、COMMIT。 -// 只看第一个词:River 每秒轮询的查询有 1.5 KB,不必扫描整条语句。 +// sqlc 和 River 的查询都以"-- name: 查询名 :类型"开头,span 以查询名命名。其他语句取第一个词,比如 BEGIN、COMMIT。 +// 只看第一个词,因为 River 每秒轮询的查询有 1.5 KB,不必扫描整条语句。 func queryName(sql string) string { rest, named := strings.CutPrefix(sql, "-- name: ") word := strings.TrimLeftFunc(rest, unicode.IsSpace) @@ -133,7 +133,7 @@ func traceRedis(rdb *goredis.Client, tracer trace.TracerProvider) { rdb.AddHook(redisSpans{tracer: tracer.Tracer(scope)}) } -// 只记命令名,不记参数:实例间消息中有成员资料和聊天内容。 +// 只记命令名,不记参数,因为实例间消息中有成员资料和聊天内容。 type redisSpans struct { tracer trace.Tracer } @@ -164,8 +164,8 @@ func (h redisSpans) ProcessPipelineHook(next goredis.ProcessPipelineHook) goredi } } -// goredis.Nil 表示键不存在,不是出错。NOSCRIPT 是 Script.Run 的正常回退:脚本不在 Redis 的缓存中时(Redis 重启或清空缓存之后 -// 每个脚本的第一次调用),EVALSHA 先收到它,随后改用 EVAL。 +// goredis.Nil 表示键不存在,不是出错。NOSCRIPT 是 Script.Run 的正常回退。脚本不在 Redis 的缓存中时(Redis 重启或清空缓存之后 +// 每个脚本的第一次调用),EVALSHA 先收到 NOSCRIPT,Script.Run 随后改用 EVAL。 func redisError(span trace.Span, err error) error { if errors.Is(err, goredis.Nil) || goredis.HasErrorPrefix(err, "NOSCRIPT") { return err @@ -173,7 +173,7 @@ func redisError(span trace.Span, err error) error { return recordError(span, err) } -// 只记方法、主机名和状态码:音源的地址中有签名、vkey 和用户的 ID。也不向音源传递追踪上下文。 +// 只记方法、主机名和状态码,因为音源的地址中有签名、vkey 和用户的 ID。也不向音源传递追踪上下文。 // span 在响应体读完或关闭时结束,下载音频的时间也算在其中。 func traceHTTP(next http.RoundTripper, tracer trace.TracerProvider) http.RoundTripper { return clientSpans{next: next, tracer: tracer.Tracer(scope)} diff --git a/internal/bootstrap/spans_test.go b/internal/bootstrap/spans_test.go index b1cc876..5194bad 100644 --- a/internal/bootstrap/spans_test.go +++ b/internal/bootstrap/spans_test.go @@ -239,7 +239,7 @@ func TestQueryName(t *testing.T) { } } -// 使用记录 span 的 provider:这样没有记录 span 是因为 ctx 中没有已采样的 span,而不是因为 provider 什么都不做。 +// 使用记录 span 的 provider。这样没有记录 span 就只能是因为 ctx 中没有已采样的 span,而不是因为 provider 什么都不做。 func TestCallsOutsideSampledTracesAllocateNothing(t *testing.T) { tracer, _ := recordingTracer(t) ctx := t.Context() diff --git a/internal/bootstrap/testdata/http/account-quota.txtar b/internal/bootstrap/testdata/http/account-quota.txtar index d8418af..6b13c49 100644 --- a/internal/bootstrap/testdata/http/account-quota.txtar +++ b/internal/bootstrap/testdata/http/account-quota.txtar @@ -1,4 +1,4 @@ -登录按客户端 IP 限流:同一个 IP 1 分钟内最多尝试 10 次,换一个用户名也算在内;请求经受信任的代理转发时,按 X-Forwarded-For 中的客户端地址计数,另一个客户端不受影响。 +登录按客户端 IP 限流。同一个 IP 1 分钟内最多尝试 10 次,换一个用户名也算在内。请求经受信任的代理转发时,按 X-Forwarded-For 中的客户端地址计数,另一个客户端不受影响。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/http/account.txtar b/internal/bootstrap/testdata/http/account.txtar index f870932..5987b4b 100644 --- a/internal/bootstrap/testdata/http/account.txtar +++ b/internal/bootstrap/testdata/http/account.txtar @@ -1,4 +1,4 @@ -账号:游客注册之后成员和成员 token 都不变;其他游客用账号登录,得到新的成员 token 和这台设备的登录设备凭证,自己原来的 token 随即失效;修改密码、退出登录让对应的成员 token 失效;后台可以查找账号、重置密码。 +账号:游客注册之后成员和成员 token 都不变。其他游客用账号登录,得到新的成员 token 和这台设备的登录设备凭证,自己原来的 token 随即失效。修改密码、退出登录让对应的成员 token 失效。后台可以查找账号、重置密码。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/http/events.txtar b/internal/bootstrap/testdata/http/events.txtar index d4c61da..9410365 100644 --- a/internal/bootstrap/testdata/http/events.txtar +++ b/internal/bootstrap/testdata/http/events.txtar @@ -1,6 +1,6 @@ -房间的事件流:bot 用常驻房间的 API Key 打开事件流,先收到快照,点歌之后收到与 WebSocket 相同的房间事件;事件流不加入房间, -快照中的在线列表一直为空。管理员重置 Key 之后,bot 的事件流收到 4401 的 close 并结束;成员用票据打开的事件流不受影响。 -房间的 5 条事件流都用了这张票据之后,再用它打开时顶替最早的一条:被顶替的事件流收到 4409 的 close 并结束。 +房间的事件流:bot 用常驻房间的 API Key 打开事件流,先收到快照,点歌之后收到与 WebSocket 相同的房间事件。事件流不加入房间, +快照中的在线列表一直为空。管理员重置 Key 之后,bot 的事件流收到 4401 的 close 并结束,成员用票据打开的事件流不受影响。 +房间的 5 条事件流都用了这张票据之后,再用这张票据打开事件流会顶替最早的一条,被顶替的事件流收到 4409 的 close 并结束。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/http/github-app.txtar b/internal/bootstrap/testdata/http/github-app.txtar index 02ed897..4b527c3 100644 --- a/internal/bootstrap/testdata/http/github-app.txtar +++ b/internal/bootstrap/testdata/http/github-app.txtar @@ -1,5 +1,5 @@ -后台配置 GitHub OAuth App:读取只给出 Client ID、Client Secret 是否已设置和要填进 GitHub 的回调地址,任何响应都不返回 Client Secret; -Client ID 和 Client Secret 一起保存,都必填;清除之后关闭 GitHub 登录。 +后台配置 GitHub OAuth App。读取时只给出 Client ID、Client Secret 是否已设置和要填进 GitHub 的回调地址,任何响应都不返回 Client Secret。 +Client ID 和 Client Secret 一起保存,两者都必填。清除之后关闭 GitHub 登录。 -- request -- GET /api/admin/credentials/github diff --git a/internal/bootstrap/testdata/http/github-disabled.txtar b/internal/bootstrap/testdata/http/github-disabled.txtar index 44b22af..da97ece 100644 --- a/internal/bootstrap/testdata/http/github-disabled.txtar +++ b/internal/bootstrap/testdata/http/github-disabled.txtar @@ -1,4 +1,4 @@ -后台没有配置 GitHub OAuth App 时,站点说明不提供 GitHub 登录,发起授权、兑换和解绑都返回 NOT_FOUND。这里的请求会跟随跳转,回调的跳转目标见 TestGitHubOAuthAppChanges。 +后台没有配置 GitHub OAuth App 时,站点说明不提供 GitHub 登录,发起授权、兑换和解绑都返回 NOT_FOUND。这个场景的请求会跟随跳转,所以回调的跳转目标见 TestGitHubOAuthAppChanges。 -- request -- GET /api/site diff --git a/internal/bootstrap/testdata/http/login-device.txtar b/internal/bootstrap/testdata/http/login-device.txtar index 833b03b..1f66264 100644 --- a/internal/bootstrap/testdata/http/login-device.txtar +++ b/internal/bootstrap/testdata/http/login-device.txtar @@ -1,4 +1,4 @@ -登录设备凭证:账号密码登录成功时返回这台设备的凭证,退出登录之后设备留着它。按用户名计数的额度 1 分钟内最多 5 次,被别人猜密码用完之后, +登录设备凭证:账号密码登录成功时返回这台设备的凭证,退出登录之后设备仍留着这个凭证。按用户名计数的额度 1 分钟内最多 5 次,被别人猜密码用完之后, 不带凭证的登录被限流,带着这个账号的凭证仍然能登录,拿到同一个凭证。改密码之后,此前签发的凭证不再被认出,同样被限流。 -- request -- diff --git a/internal/bootstrap/testdata/http/origin.txtar b/internal/bootstrap/testdata/http/origin.txtar index 807bcd5..00ee4e8 100644 --- a/internal/bootstrap/testdata/http/origin.txtar +++ b/internal/bootstrap/testdata/http/origin.txtar @@ -1,4 +1,4 @@ -带 Origin 的请求只接受显式允许列表中的来源;不带 Origin 的请求不检查。 +带 Origin 的请求只接受显式允许列表中的来源。不带 Origin 的请求不检查。 -- request -- POST /api/rooms diff --git a/internal/bootstrap/testdata/http/pairing.txtar b/internal/bootstrap/testdata/http/pairing.txtar index d3d26f3..3cc09d6 100644 --- a/internal/bootstrap/testdata/http/pairing.txtar +++ b/internal/bootstrap/testdata/http/pairing.txtar @@ -1,4 +1,4 @@ -设备配对:已注册的账号生成配对码,游客兑换之后登录这个账号,原来的游客 token 随即失效;配对码只能兑换一次,游客不能生成,生成它的会话退出之后配对码作废。 +设备配对:已注册的账号生成配对码,游客兑换之后登录这个账号,原来的游客 token 随即失效。配对码只能兑换一次,游客不能生成。生成配对码的会话退出之后,配对码作废。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/http/playlist-imports.txtar b/internal/bootstrap/testdata/http/playlist-imports.txtar index 6d557fa..61aea83 100644 --- a/internal/bootstrap/testdata/http/playlist-imports.txtar +++ b/internal/bootstrap/testdata/http/playlist-imports.txtar @@ -1,4 +1,4 @@ -歌单导入:只有已注册的账号能导入。导入文件时,曲目表里已有的歌用服务端的元数据,没有的用文件里的元数据(不是平台自己的 https 地址的封面和页面链接被丢弃,链接曲目没有封面);重复的歌只留第一次出现的位置,标题为空的跳过,来源中的第一首排在歌单最前。链接可以识别出平台歌单,目前只能导入网易云的歌单。 +歌单导入:只有已注册的账号能导入。导入文件时,曲目表里已有的歌用服务端的元数据,没有的用文件里的元数据。文件里的封面和页面链接不是平台自己的 https 地址时被丢弃,链接曲目没有封面。重复的歌只留第一次出现的位置,标题为空的跳过,来源中的第一首排在歌单最前。链接可以识别出平台歌单,目前只能导入网易云的歌单。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/http/playlist-shares.txtar b/internal/bootstrap/testdata/http/playlist-shares.txtar index 4f11b6a..e7dcead 100644 --- a/internal/bootstrap/testdata/http/playlist-shares.txtar +++ b/internal/bootstrap/testdata/http/playlist-shares.txtar @@ -1,4 +1,4 @@ -歌单分享:已注册的账号可以分享自己的任何歌单,包括“我喜欢”;每次开启都生成新的链接,旧链接随即失效;不需要身份就能读取分享,响应里没有所有者的成员 ID 和用户名;已注册的成员可以把分享的歌单复制成自己的新歌单;停止分享、删除歌单之后分享失效。 +歌单分享:已注册的账号可以分享自己的任何歌单,包括“我喜欢”。每次开启都生成新的链接,旧链接随即失效。不需要身份就能读取分享,响应里没有所有者的成员 ID 和用户名。已注册的成员可以把分享的歌单复制成自己的新歌单。停止分享、删除歌单之后分享失效。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/http/playlists.txtar b/internal/bootstrap/testdata/http/playlists.txtar index a15a52e..ce4bbe6 100644 --- a/internal/bootstrap/testdata/http/playlists.txtar +++ b/internal/bootstrap/testdata/http/playlists.txtar @@ -1,4 +1,4 @@ -歌单:只有已注册的账号能使用;注册时自带不能重命名、不能删除的“我喜欢”;收藏时元数据由服务端查询,同一首歌只收一次,新加入的在前;可以批量移出、复制到别的歌单、调整顺序;别人的歌单和不存在的一样。 +歌单:只有已注册的账号能使用。注册时自带一个“我喜欢”,不能重命名,也不能删除。收藏时元数据由服务端查询,同一首歌只收一次,新加入的在前。可以批量移出、复制到别的歌单、调整顺序。别人的歌单和不存在的一样。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/http/room-requests.txtar b/internal/bootstrap/testdata/http/room-requests.txtar index cbf7040..face09a 100644 --- a/internal/bootstrap/testdata/http/room-requests.txtar +++ b/internal/bootstrap/testdata/http/room-requests.txtar @@ -1,6 +1,6 @@ -bot 用进房票据调用 /api/room 下的接口,响应与 WebSocket 上的相同:读操作用 GET,参数放在路径和查询串中 -(keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6 是“周杰伦”);点歌返回 201 和新条目的地址,删歌、切换播放模式和聊天返回 204, -没有字段的请求可以不带请求体。bot 没有会话,不出现在在线列表中;它用事件流等第一首歌开播,再投票切歌。 +bot 用进房票据调用 /api/room 下的接口,响应与 WebSocket 上的相同。读操作用 GET,参数放在路径和查询串中 +(keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6 是“周杰伦”)。点歌返回 201 和新条目的地址,删歌、切换播放模式和聊天返回 204, +没有字段的请求可以不带请求体。bot 没有会话,不出现在在线列表中。bot 用事件流等第一首歌开播,再投票切歌。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/http/session-errors.txtar b/internal/bootstrap/testdata/http/session-errors.txtar index 542354b..4003624 100644 --- a/internal/bootstrap/testdata/http/session-errors.txtar +++ b/internal/bootstrap/testdata/http/session-errors.txtar @@ -1,4 +1,4 @@ -成员 token 无效时返回 UNAUTHORIZED;请求体不合法时返回 INVALID_ARGUMENT。 +成员 token 无效时返回 UNAUTHORIZED。请求体不合法时返回 INVALID_ARGUMENT。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/http/site.txtar b/internal/bootstrap/testdata/http/site.txtar index e55cc4b..eb8d4c9 100644 --- a/internal/bootstrap/testdata/http/site.txtar +++ b/internal/bootstrap/testdata/http/site.txtar @@ -1,4 +1,4 @@ -站点:品牌名默认是 Alisten,默认开放注册;后台修改品牌名时返回存下的名字,首尾的空白已经去掉,之后读到的也是它;空的、超过 20 个字符的和看不见的名字不能保存;后台可以关闭和重新开放注册,关闭之后游客不能注册;修改站点设置要管理 token。 +站点:品牌名默认是 Alisten,默认开放注册。后台修改品牌名时返回存下的名字,首尾的空白已经去掉,之后读到的也是这个名字。空的、超过 20 个字符的和看不见的名字不能保存。后台可以关闭和重新开放注册,关闭之后游客不能注册。修改站点设置要管理 token。 -- request -- GET /api/site diff --git a/internal/bootstrap/testdata/http/tickets.txtar b/internal/bootstrap/testdata/http/tickets.txtar index 894a718..ed829f8 100644 --- a/internal/bootstrap/testdata/http/tickets.txtar +++ b/internal/bootstrap/testdata/http/tickets.txtar @@ -1,4 +1,4 @@ -成员用成员 token 换取进房票据;有密码的房间先校验密码。 +成员用成员 token 换取进房票据。有密码的房间先校验密码。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/ws/account-sessions.txtar b/internal/bootstrap/testdata/ws/account-sessions.txtar index a4a0b1d..6971aa0 100644 --- a/internal/bootstrap/testdata/ws/account-sessions.txtar +++ b/internal/bootstrap/testdata/ws/account-sessions.txtar @@ -1,5 +1,5 @@ -票据属于签发它的会话:账号在两台设备上各自进入同一个房间,一台设备修改密码之后,另一台设备的 WebSocket 以 4401 断开, -用它的票据打开的事件流收到 4401 的 close;这台设备的旧票据再也不能用来进房或调用房间接口。发出请求的设备不受影响。 +票据属于签发它的会话。账号在两台设备上各自进入同一个房间,一台设备修改密码之后,另一台设备的 WebSocket 以 4401 断开, +用另一台设备的票据打开的事件流收到 4401 的 close。另一台设备的旧票据再也不能用来进房或调用房间接口。发出请求的设备不受影响。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/ws/admin.txtar b/internal/bootstrap/testdata/ws/admin.txtar index 3131bbd..036e86b 100644 --- a/internal/bootstrap/testdata/ws/admin.txtar +++ b/internal/bootstrap/testdata/ws/admin.txtar @@ -1,4 +1,4 @@ -后台修改和删除常驻房间。修改名称和描述后,房间广播新的房间信息;设置密码之后旧票据作废,会话以 4401 断开; +后台修改和删除常驻房间。修改名称和描述后,房间广播新的房间信息。设置密码之后旧票据作废,会话以 4401 断开。 删除房间之后会话以 4404 断开,房间和它的票据一起删除。临时房间不归后台管理。 -- request -- diff --git a/internal/bootstrap/testdata/ws/api-key.txtar b/internal/bootstrap/testdata/ws/api-key.txtar index 5168867..bc42b39 100644 --- a/internal/bootstrap/testdata/ws/api-key.txtar +++ b/internal/bootstrap/testdata/ws/api-key.txtar @@ -1,5 +1,5 @@ -常驻房间的 API Key:管理员先看到还没有 Key,生成时拿到 Key,之后只看到已经生成;bot 用它通过 HTTP 点歌并代填点歌人, -条目的点歌人是“张三(bot)”;普通成员不能代填;Key 不能建立 WebSocket 会话。重置之后旧 Key 返回 401; +常驻房间的 API Key:管理员先看到还没有 Key,生成时拿到 Key,之后只看到已经生成。bot 用 Key 通过 HTTP 点歌并代填点歌人, +条目的点歌人是“张三(bot)”。普通成员不能代填。Key 不能建立 WebSocket 会话。重置之后旧 Key 返回 401。 设置密码之后成员的会话以 4401 断开,Key 照常可用。临时房间没有 Key。 -- request -- diff --git a/internal/bootstrap/testdata/ws/autodj.txtar b/internal/bootstrap/testdata/ws/autodj.txtar index 728eb90..e22f4a2 100644 --- a/internal/bootstrap/testdata/ws/autodj.txtar +++ b/internal/bootstrap/testdata/ws/autodj.txtar @@ -1,5 +1,5 @@ AutoDJ 自动补歌:网易云的歌开播时,等待队列为空、有人在听、10 秒内没有人点歌,就按房间的播放记录推荐一首相似的歌, -以 AutoDJ 的名义入队并准备。《片头》只有 15 秒,它放完、晴天开播时,晴天已经是 15 秒前点的了:比 10 秒多出的 5 秒, +以 AutoDJ 的名义入队并准备。《片头》只有 15 秒,《片头》放完、晴天开播时,晴天已经是 15 秒前点的了。比 10 秒多出的 5 秒, 是点晴天的消息从发出到房间收到可以耽搁的时间。 -- request -- diff --git a/internal/bootstrap/testdata/ws/bilibili.txtar b/internal/bootstrap/testdata/ws/bilibili.txtar index 67d6e40..7d98710 100644 --- a/internal/bootstrap/testdata/ws/bilibili.txtar +++ b/internal/bootstrap/testdata/ws/bilibili.txtar @@ -1,5 +1,5 @@ -B 站:临时房间可以点 B 站的视频,完整链接和 b23.tv 短链识别成同一首;认得出但不能点的链接(番剧)返回音源给出的原因; -直接给出的曲目 ID 格式不对时返回 INVALID_ARGUMENT。没有 worker,点到的歌一直在准备中。 +B 站:临时房间可以点 B 站的视频,完整链接和 b23.tv 短链识别成同一首。认得出但不能点的链接(番剧)返回音源给出的原因。 +直接给出的曲目 ID 格式不对时返回 INVALID_ARGUMENT。没有 worker,所以点到的歌一直在准备中。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/ws/catalog.txtar b/internal/bootstrap/testdata/ws/catalog.txtar index 5a8a441..2110177 100644 --- a/internal/bootstrap/testdata/ws/catalog.txtar +++ b/internal/bootstrap/testdata/ws/catalog.txtar @@ -1,5 +1,5 @@ -网关直接回复搜索、歌单和推荐列表的请求,它们不经过房间,房间的事件编号不变。搜索结果在上游一次取回的结果中分页; -QQ 音乐不提供歌单内的歌曲,链接不支持搜索;推荐列表取正在播放的网易云歌曲的相似歌曲,去掉种子本身。常驻房间的搜索不限额度。 +网关直接回复搜索、歌单和推荐列表的请求,这些请求不经过房间,所以房间的事件编号不变。搜索结果在上游一次取回的结果中分页。 +QQ 音乐不提供歌单内的歌曲,链接不支持搜索。推荐列表取正在播放的网易云歌曲的相似歌曲,去掉种子本身。常驻房间的搜索不限额度。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/ws/handshake.txtar b/internal/bootstrap/testdata/ws/handshake.txtar index ee534bb..bff805a 100644 --- a/internal/bootstrap/testdata/ws/handshake.txtar +++ b/internal/bootstrap/testdata/ws/handshake.txtar @@ -1,4 +1,4 @@ -握手:第一帧必须是 hello。失败时先回复错误,再以对应的关闭码断开;先检查房间是否存在,再检查票据。 +握手:第一帧必须是 hello。失败时先回复错误,再以对应的关闭码断开。先检查房间是否存在,再检查票据。 列表外来源的握手以 HTTP 403 拒绝升级。 -- request -- diff --git a/internal/bootstrap/testdata/ws/listens.txtar b/internal/bootstrap/testdata/ws/listens.txtar index 0469958..c415cec 100644 --- a/internal/bootstrap/testdata/ws/listens.txtar +++ b/internal/bootstrap/testdata/ws/listens.txtar @@ -1,5 +1,5 @@ -播放历史:开播时记给房间里的成员,开播之后进来的成员也记上正在播放的歌,关闭了记录的成员不记,已有的历史保留; -可以删除一首、清空;游客登录账号时,游客的历史并入账号。读取历史要有成员 token。 +播放历史:开播时记给房间里的成员,开播之后进来的成员也记上正在播放的歌。关闭了记录的成员不记,已有的历史保留。 +可以删除一首,也可以清空。游客登录账号时,游客的历史并入账号。读取历史要有成员 token。 -- request -- POST /api/members diff --git a/internal/bootstrap/testdata/ws/queue.txtar b/internal/bootstrap/testdata/ws/queue.txtar index 98962eb..89c0d42 100644 --- a/internal/bootstrap/testdata/ws/queue.txtar +++ b/internal/bootstrap/testdata/ws/queue.txtar @@ -1,4 +1,4 @@ -点歌、点赞、删歌、投票切歌和播放模式。网易云的歌先查元数据再入队;按关键词点歌取搜索结果的第一条; +点歌、点赞、删歌、投票切歌和播放模式。网易云的歌先查元数据再入队。按关键词点歌取搜索结果的第一条。 入队的歌先是准备中,准备好之后开播,正在播放的歌带有音频地址、时长、增益和歌词。回复排在同一批的事件之后。 -- request -- diff --git a/internal/bootstrap/testdata/ws/session.txtar b/internal/bootstrap/testdata/ws/session.txtar index 535aaaf..ac853f8 100644 --- a/internal/bootstrap/testdata/ws/session.txtar +++ b/internal/bootstrap/testdata/ws/session.txtar @@ -1,5 +1,5 @@ -WebSocket 会话:握手、心跳、聊天、快照和在线列表。加入时的在线列表已包含在握手的快照中; -断开的会话离开房间,房间列表的在线人数随之变化;修改资料后,所在房间广播新的在线列表。 +WebSocket 会话:握手、心跳、聊天、快照和在线列表。加入时的在线列表已包含在握手的快照中。 +断开的会话离开房间,房间列表的在线人数随之变化。修改资料后,所在房间广播新的在线列表。 -- request -- POST /api/members diff --git a/internal/bootstrap/tracing.go b/internal/bootstrap/tracing.go index 6515c5a..4c8ea71 100644 --- a/internal/bootstrap/tracing.go +++ b/internal/bootstrap/tracing.go @@ -17,7 +17,7 @@ import ( "go.opentelemetry.io/otel/trace/noop" ) -// serve 和 worker 自己的退出期限加上它,仍在 Kubernetes 30 秒的退出宽限期之内。 +// serve 和 worker 自己的退出期限加上 flushTimeout,仍在 Kubernetes 30 秒的退出宽限期之内。 const flushTimeout = 2 * time.Second // export 为假时返回不记录 span 的 provider,日志中也就没有 trace_id 和 span_id。导出器按 OpenTelemetry 的规范 @@ -43,7 +43,7 @@ func newTracerProvider(ctx context.Context, export bool, service, instance strin return provider, provider.Shutdown, nil } -// 先导出剩下的 span,再写退出日志:导出失败的告警排在退出日志之前,退出日志才是进程真正退出之前的最后一条。 +// 先导出剩下的 span,再写退出日志。这样导出失败的告警排在退出日志之前,退出日志才是进程真正退出之前的最后一条。 // 进程出错退出时(err 不为空)由调用方记录错误,不写退出日志。 func exitAfterFlush(err error, flush func(context.Context) error, logger *slog.Logger, exited string) { ctx, cancel := context.WithTimeout(context.Background(), flushTimeout) @@ -57,8 +57,8 @@ func exitAfterFlush(err error, flush func(context.Context) error, logger *slog.L } // OpenTelemetry 自己的日志默认以纯文本写到 stderr,导出失败之类的错误也只交给标准库 log 包。 -// 它的字段放进 otel 分组:创建 tracer 的日志带有 version 字段,不能与日志的固定字段 version 重名。 -// 它还会把写错的环境变量的原值记成字段(比如 OTEL_EXPORTER_OTLP_HEADERS 中的凭证),所以只保留 err 字段的值。 +// OpenTelemetry 日志的字段放进 otel 分组,因为创建 tracer 的日志带有 version 字段,不能与日志的固定字段 version 重名。 +// OpenTelemetry 还会把写错的环境变量的原值记成字段(比如 OTEL_EXPORTER_OTLP_HEADERS 中的凭证),所以只保留 err 字段的值。 func routeOpenTelemetryLogs(logger *slog.Logger) { otel.SetLogger(logr.FromSlogHandler(errorsOnly{logger.WithGroup("otel").Handler()})) otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { diff --git a/internal/bootstrap/tracing_test.go b/internal/bootstrap/tracing_test.go index cafd698..f3cb8dc 100644 --- a/internal/bootstrap/tracing_test.go +++ b/internal/bootstrap/tracing_test.go @@ -148,7 +148,7 @@ func TestOpenTelemetryLogsAreJSON(t *testing.T) { } } -// 逐个读取 token:解码成 map 时,后一个值会静默覆盖前一个,看不出重复。 +// 逐个读取 token,因为解码成 map 时,后一个值会静默覆盖前一个,看不出重复。 func duplicateKeys(t *testing.T, line string) []string { t.Helper() type object struct { diff --git a/internal/bootstrap/worker.go b/internal/bootstrap/worker.go index 04fbf37..3397de3 100644 --- a/internal/bootstrap/worker.go +++ b/internal/bootstrap/worker.go @@ -41,7 +41,7 @@ import ( ) const ( - // 停止 River client 和关闭运维端口共用这段期限,加上导出剩余追踪的 flushTimeout,不能超过 Kubernetes 30 秒的退出宽限期。 + // 停止 River client 和关闭运维端口共用这段期限。这段期限加上导出剩余追踪的 flushTimeout,不能超过 Kubernetes 30 秒的退出宽限期。 workerStopTimeout = 25 * time.Second publishTimeout = 5 * time.Second waitingInterval = 15 * time.Second @@ -143,9 +143,9 @@ func runWorker(base *slog.Logger) (err error) { return w.run(signals, listener) } -// 任务的临时目录都建在 TMPDIR 下以主机名命名的目录中。进程崩溃时来不及清理的任务目录会留在那里; -// 同一个容器(Kubernetes 中的同一个 Pod)重启后主机名不变,启动时在这里清除。其他容器的目录不动: -// 它们可能共用同一个 TMPDIR,任务仍在运行。每个容器只能运行一个 worker。 +// 任务的临时目录都建在 TMPDIR 下以主机名命名的目录中。进程崩溃时来不及清理的任务目录会留在那里。 +// 同一个容器(Kubernetes 中的同一个 Pod)重启后主机名不变,所以启动时在这里清除。其他容器的目录不动, +// 因为其他容器可能共用同一个 TMPDIR,任务仍在运行。每个容器只能运行一个 worker。 func prepareWorkDir() (string, error) { host, err := os.Hostname() if err != nil { @@ -186,12 +186,12 @@ func newMediaWorker(cfg workerConfig, pool *pgxpool.Pool, rdb *goredis.Client, s if err != nil { return mediaWorker{}, fmt.Errorf("创建 River client:%w", err) } - // worker 只发布准备结果、不订阅,不会丢弃实例间消息,所以不暴露丢弃的计数;也不会收到房间输入,所以不记录收到输入的 span。 + // worker 只发布准备结果、不订阅,不会丢弃实例间消息,所以不暴露丢弃的计数。worker 也不会收到房间输入,所以不记录收到输入的 span。 bus := redis.NewBus(rdb, instance, version, prometheus.NewRegistry(), noop.NewTracerProvider(), base.With("component", "bus")) publish := func(o port.PrepareOutcome) { ctx, cancel := context.WithTimeout(logctx.WithJob(context.Background(), o.JobID), publishTimeout) defer cancel() - // 广播失败时只记录日志:等待的一方会定期按任务 ID 核对。 + // 广播失败时只记录日志,因为等待的一方会定期按任务 ID 核对。 if err := bus.PublishPrepared(ctx, o); err != nil { logger.WarnContext(ctx, "广播准备结果失败", "source", o.Track.Source, "track_id", o.Track.ID, "error", err) } @@ -209,7 +209,7 @@ func newMediaWorker(cfg workerConfig, pool *pgxpool.Pool, rdb *goredis.Client, s }, nil } -// 一直运行到 ctx 结束,或者运维端口、River client 意外停止;后者以错误返回,交给容器编排重启。 +// 一直运行到 ctx 结束,或者运维端口、River client 意外停止。意外停止时返回错误,交给容器编排重启。 // 数据库迁移完成之前保持未就绪,迁移完成之后才启动 River client。 func (w mediaWorker) run(ctx context.Context, listener net.Listener) error { var dependenciesReady, stopping atomic.Bool @@ -235,7 +235,7 @@ func (w mediaWorker) run(ctx context.Context, listener net.Listener) error { case <-background.Done(): return } - // 开始退出之后不再启动 River client:此后才启动的 client 不会被停止,运行中的任务要等 20 分钟才由 River 重新调度。 + // 开始退出之后不再启动 River client,因为这时才启动的 client 不会被停止,运行中的任务要等 20 分钟才由 River 重新调度。 starting.Lock() if stopping.Load() { starting.Unlock() @@ -306,7 +306,7 @@ func (w mediaWorker) run(ctx context.Context, listener net.Listener) error { return errors.Join(errs...) } -// 统计失败时保留上一次的值:连不上数据库已经反映在就绪检查中。 +// 统计失败时保留上一次的值,因为连不上数据库已经反映在就绪检查中。 func watchWaitingJobs(ctx context.Context, count func(context.Context) (int64, error), waiting prometheus.Gauge) { ticker := time.NewTicker(waitingInterval) defer ticker.Stop() diff --git a/internal/bootstrap/worker_test.go b/internal/bootstrap/worker_test.go index ba7f2ad..a5b2a0f 100644 --- a/internal/bootstrap/worker_test.go +++ b/internal/bootstrap/worker_test.go @@ -257,7 +257,7 @@ func TestWorkerPreparesBilibili(t *testing.T) { storageURL := objects.Endpoint + "/" + objects.Bucket t.Setenv("STORAGE_PUBLIC_URL", storageURL) svc := startService(t) - // serve 和 worker 各自构造音源:serve 的那份照常给出付费视频的元数据,worker 的这份拒绝下载它,与真实的平台一样。 + // serve 和 worker 各自构造音源。serve 的音源照常给出付费视频的元数据,worker 的音源拒绝下载付费视频,与真实的平台一样。 bilibili := contractBilibili() bilibili.RejectVideo("BV1GJ411x7h7", "这个视频需要付费") transcoder := porttest.NewTranscoder(porttest.SilentWAV(2 * time.Second)) @@ -464,7 +464,7 @@ func TestWorkerIsNotReadyWhileStopping(t *testing.T) { } blocked := &blockedLinks{entered: make(chan struct{}), release: make(chan struct{})} w := startWorker(t, blocked) - // Cleanup 按注册的逆序执行:先放行任务,startWorker 注册的清理才能等到 worker 退出。 + // Cleanup 按注册的逆序执行,所以先放行任务,startWorker 注册的清理才能等到 worker 退出。 release := sync.OnceFunc(func() { close(blocked.release) }) t.Cleanup(release) w.waitReady(t) @@ -534,7 +534,7 @@ func TestWorkerExitsAfterFlushingTraces(t *testing.T) { runErr = runWorker(newLogger(&logs, slog.LevelInfo)) }() eventually(t, "/readyz 返回 200", finished, func() bool { return readyStatus(opsListen) == http.StatusOK }) - // 执行任务的 span 是 worker 记下的唯一一个 span。任务执行一次就失败,span 随之结束:回环地址会被 yt-dlp 的代理拒绝; + // 执行任务的 span 是 worker 记下的唯一一个 span。回环地址会被 yt-dlp 的代理拒绝,所以任务执行一次就失败,span 随之结束。 // 本机没有 yt-dlp 的话,任务在启动 yt-dlp 这一步就失败。 cfg, err := loadDatabaseConfig() if err != nil { diff --git a/internal/domain/playlist/link.go b/internal/domain/playlist/link.go index cc7762f..faf9920 100644 --- a/internal/domain/playlist/link.go +++ b/internal/domain/playlist/link.go @@ -8,7 +8,7 @@ import ( "github.com/dihemoe/alisten/internal/domain/track" ) -// URL 是从文本中取出的链接;短链接要靠它去解析。 +// URL 是从文本中取出的链接。短链接要靠 URL 去解析。 type Link struct { Source track.Source ID string diff --git a/internal/domain/playlist/order.go b/internal/domain/playlist/order.go index d29289a..78c0b5d 100644 --- a/internal/domain/playlist/order.go +++ b/internal/domain/playlist/order.go @@ -12,8 +12,8 @@ var ( ErrBeforeMissing = errors.New("歌单已经变了,刷新之后再试") ) -// order 是显示顺序;返回移动后的显示顺序。moved 里不在 order 中的歌忽略,重复的只算一次; -// 都不在 order 中时原样返回,此时不检查 before 是否还在。 +// order 是显示顺序,返回移动后的显示顺序。moved 里不在 order 中的歌忽略,重复的只算一次。 +// moved 里的歌都不在 order 中时原样返回,这时不检查 before 是否还在。 func Move(order []track.Track, moved []track.Track, before *track.Track) ([]track.Track, error) { if before != nil && slices.Contains(moved, *before) { return nil, ErrBeforeMoved diff --git a/internal/domain/room/fuzz_test.go b/internal/domain/room/fuzz_test.go index d7792b3..f7e726d 100644 --- a/internal/domain/room/fuzz_test.go +++ b/internal/domain/room/fuzz_test.go @@ -13,11 +13,11 @@ import ( ) const ( - // 房间换到其他实例运行:以下一个任期从存储中的状态恢复。 + // 房间换到其他实例运行,以下一个任期从存储中的状态恢复。 fuzzRestore = 17 // 准备结果写进了曲目表,却没有送到房间(房间没有 owner,或者通知丢失)。 fuzzStored = 18 - // 曲目表中的结果不能再用:比如链接曲目的音频已经不在对象存储中。 + // 曲目表中的结果不能再用了,比如链接曲目的音频已经不在对象存储中。 fuzzDropped = 19 fuzzOps = 20 ) @@ -35,9 +35,9 @@ func FuzzInvariants(f *testing.F) { // 恢复时待播和等待队列中准备中的歌重新准备。 f.Add(false, []byte{2, 0, 17, 0}) f.Add(true, []byte{2, 0, 7, 0, 2, 1, 17, 0}) - // 待播准备好的结果在房间没有 owner 时写进了曲目表:恢复时待播已就绪,立即开播。 + // 待播准备好的结果在房间没有 owner 时写进了曲目表,所以恢复时待播已就绪,立即开播。 f.Add(true, []byte{2, 0, 18, 0, 17, 0}) - // 等待队列中已就绪歌曲的结果不能再用:恢复时重新准备。 + // 等待队列中已就绪歌曲的结果不能再用了,恢复时要重新准备。 f.Add(true, []byte{2, 0, 7, 0, 2, 1, 7, 1, 19, 1, 17, 0}) f.Fuzz(func(t *testing.T, persistent bool, ops []byte) { kind := Ephemeral @@ -57,7 +57,7 @@ func FuzzInvariants(f *testing.F) { var effects []Effect switch op % fuzzOps { case fuzzRestore: - // 每次状态变化都已落盘,所以落盘的状态就是 before;会话、投票和定时器不落盘,随旧的 Room 一起丢弃。 + // 每次状态变化都已落盘,所以落盘的状态就是 before。会话、投票和定时器不落盘,随旧的 Room 一起丢弃。 term++ seq = 0 before = stored(before, table) @@ -76,7 +76,7 @@ func FuzzInvariants(f *testing.F) { } effects = r.Handle(env, in) } - // 房间关闭后就被丢弃,不会再收到输入;关闭时结束了正在播放的歌,但不再开播下一首。 + // 房间关闭后就被丢弃,不会再收到输入。关闭时结束了正在播放的歌,但不再开播下一首。 closed := len(find[Close](effects)) != 0 checkInvariants(t, r, before, effects, term, &seq, closed) if closed { @@ -90,7 +90,7 @@ func fuzzTrack(arg byte) track.Track { return netease(string(rune('a' + arg%6))) } -// 存储接管房间时按曲目表重算待播和等待队列中的条目是否就绪;正在播放的歌已经开播,保持原样。 +// 存储接管房间时按曲目表重算待播和等待队列中的条目是否就绪。正在播放的歌已经开播,保持原样。 func stored(s Snapshot, table map[track.Track]track.Prepared) Snapshot { restore := func(e Entry) Entry { e.Prep, e.Ready = table[e.Track] @@ -127,7 +127,7 @@ func checkRestorePrepares(t *testing.T, r *Room, effects []Effect) { } } -// 不能用 sameSnapshot 判断持久状态是否变化:要检查的正是它。 +// 不能用 sameSnapshot 判断持久状态是否变化,因为要检查的正是 sameSnapshot。 func persistentStateChanged(a, b Snapshot) bool { if len(a.Waiting) == 0 && len(b.Waiting) == 0 { a.Waiting, b.Waiting = nil, nil @@ -178,7 +178,7 @@ func fuzzInput(r *Room, op, arg byte) Input { } } -// 多数时候选中等待队列中的条目,也会选中待播、正在播放和不存在的条目:删除和点赞只接受等待队列中的条目。 +// 多数时候选中等待队列中的条目,因为删除和点赞只接受这些条目。待播、正在播放和不存在的条目也会选到。 func fuzzEntry(r *Room, arg byte) EntryID { candidates := ids(r.waiting) if r.pending != nil { diff --git a/internal/domain/room/limiter.go b/internal/domain/room/limiter.go index ee1cb39..095e27a 100644 --- a/internal/domain/room/limiter.go +++ b/internal/domain/room/limiter.go @@ -2,7 +2,7 @@ package room import "time" -// 令牌桶用 GCRA 的理论到达时间表示,只做整数时间运算:浮点运算在不同平台上可能被编译器融合成不同的结果,状态机就不再确定。 +// 令牌桶用 GCRA 的理论到达时间表示,只做整数时间运算。浮点运算在不同平台上可能被编译器融合成不同的结果,那样状态机就不再确定。 type limiter struct { tat time.Time } diff --git a/internal/domain/room/playback.go b/internal/domain/room/playback.go index 599271f..3c6d772 100644 --- a/internal/domain/room/playback.go +++ b/internal/domain/room/playback.go @@ -46,7 +46,7 @@ func (r *Room) endCurrent(b *batch, at time.Time) { c := *r.current r.current = nil r.endTimer = 0 - // 推荐只对发出它的那首歌有效。recommended 也依赖这一点:token 有效时 current 一定非空。 + // 推荐只对发出它的那首歌有效。所以 token 有效时 current 一定非空,recommended 也依赖这一点。 r.recommendation = 0 // 开播前的 startLead 之内被切掉时,结束时间不能早于开始时间。 if at.Before(c.StartedAt) { @@ -55,7 +55,7 @@ func (r *Room) endCurrent(b *batch, at time.Time) { b.plays = append(b.plays, RecordPlay{Entry: c.Entry, StartedAt: c.StartedAt, EndedAt: at}) } -// 房间关闭时还在播放的歌也算结束,要写播放记录;关闭可能晚于歌曲原定的结束时间送达,记录不能比歌曲本身还长。 +// 房间关闭时还在播放的歌也算结束,要写播放记录。关闭可能在歌曲原定的结束时间之后才送达,所以记录不能比歌曲本身还长。 func (r *Room) stopPlayback(b *batch) { if r.current == nil { return diff --git a/internal/domain/room/queue.go b/internal/domain/room/queue.go index adc15c3..d0da708 100644 --- a/internal/domain/room/queue.go +++ b/internal/domain/room/queue.go @@ -103,9 +103,10 @@ func (r *Room) add(b *batch, t track.Track, m track.Meta, by member.Member, auto return e } -// 轮次在入队时定下,之后不再改变:新歌排进最早还没放完的一轮;点歌人还有歌在队列中,或者已经有歌开播,就至少排到这些歌中最高一轮的下一轮。 -// 最早的一轮不一定是正在播放的那一轮,点赞会让靠后轮次的歌先放。 -// 点歌人按成员 ID 和名字区分:bot 用同一个成员替不同的人点歌;AutoDJ 加入的歌没有点歌人,算作同一个点歌人。 +// 轮次在入队时定下,之后不再改变。新歌一般排进还没放完的轮次中最早的一轮。如果点歌人已经有歌在队列里,或者已经有歌开播,就至少排到这些歌里最高一轮的下一轮。 +// 最早的一轮不一定是正在播放的那一轮,因为点赞会让靠后轮次的歌先放。 +// 判断是不是同一个点歌人,要同时看成员 ID 和名字,因为 bot 会用同一个成员替不同的人点歌。 +// AutoDJ 加的歌没有点歌人,都算作同一个点歌人。 func (r *Room) nextRound(by member.Member) int { entries := slices.Clone(r.waiting) if r.pending != nil { @@ -120,7 +121,7 @@ func (r *Room) nextRound(by member.Member) int { return 0 } round := slices.MinFunc(entries, func(a, b Entry) int { return cmp.Compare(a.Round, b.Round) }).Round - // 早于最早一轮的记录不会再影响轮次;bot 代填的点歌人名字不受限制,不删就会一直增长。 + // 早于最早一轮的记录不会再影响轮次。bot 代填的点歌人名字不受限制,不删的话 served 会一直增长。 maps.DeleteFunc(r.served, func(_ requester, served int) bool { return served < round }) key := requesterOf(by) if served, ok := r.served[key]; ok { diff --git a/internal/domain/room/queue_test.go b/internal/domain/room/queue_test.go index 398419b..fc8ce25 100644 --- a/internal/domain/room/queue_test.go +++ b/internal/domain/room/queue_test.go @@ -24,7 +24,7 @@ func TestEphemeralRoomLimits(t *testing.T) { r := newRoom(t, Ephemeral) wantError(t, r.Handle(at(0), enqueue(alice, track.Track{Source: track.Link, ID: "https://example.com/a"})), Forbidden) - // 每分钟点一首,额度不会用完;第一首成为待播,之后 10 首进入等待队列。 + // 每分钟点一首,额度不会用完。第一首成为待播,之后 10 首进入等待队列。 for i := range 11 { res := reply(t, r.Handle(at(time.Duration(i+1)*time.Minute), enqueue(alice, netease(string(rune('a'+i)))))) if res.Err != nil { @@ -36,7 +36,7 @@ func TestEphemeralRoomLimits(t *testing.T) { func TestPersistentRoomQueueLimit(t *testing.T) { r := newRoom(t, Persistent) - // 每分钟点一首,额度不会用完;第一首成为待播,之后 200 首进入等待队列。 + // 每分钟点一首,额度不会用完。第一首成为待播,之后 200 首进入等待队列。 for i := range 201 { if res := reply(t, r.Handle(at(time.Duration(i+1)*time.Minute), enqueue(alice, netease(fmt.Sprint(i))))); res.Err != nil { t.Fatalf("第 %d 首点歌失败:%#v", i+1, res) @@ -116,7 +116,7 @@ func TestRotationRemembersSongsThatHavePlayed(t *testing.T) { pickReady(r, 0, alice, "a1") pickReady(r, 0, alice, "a2") pickReady(r, 0, bob, "b1") - // 乙和丙轮流点歌,每次都等上一首放完才点:放过第 0 轮的人再点歌要排到第 1 轮,甲的第二首才不会一直被插队。 + // 乙和丙轮流点歌,每次都等上一首放完才点。放过第 0 轮的人再点歌要排到第 1 轮,这样甲的第二首才不会一直被插队。 var played []string for _, next := range []struct { by member.Member diff --git a/internal/domain/room/room.go b/internal/domain/room/room.go index 2786a09..5ab22e7 100644 --- a/internal/domain/room/room.go +++ b/internal/domain/room/room.go @@ -40,13 +40,13 @@ type Room struct { endTimer Token closeTimer Token recommendation Token - // 各点歌人已开播的最高轮次,只在内存中:换 owner 之后刚放过歌的点歌人可能提前一轮,已在队列中的歌不受影响。 + // 各点歌人已开播的最高轮次。served 只在内存中,所以换 owner 之后,刚放过歌的点歌人可能提前一轮。已在队列中的歌不受影响。 served map[requester]int } -// token 每次 Restore 后都从 1 重新开始。放弃房间后再次恢复时,调用方必须作废旧 Room -// 尚未触发的定时器和尚未返回的推荐,否则旧 token 会被新 Room 当成自己的。 -// 返回的 effect 与 Handle 的要求相同。 +// 每次 Restore 之后,token 都从 1 重新编号。所以放弃一个房间再恢复时,调用方必须先作废旧 Room +// 还没触发的定时器和还没返回的推荐,否则它们的 token 会和新 Room 的撞号,被新 Room 当成自己的。 +// 返回的 effect 和 Handle 返回的一样,按同样的规则执行。 func Restore(env Env, term uint32, s Snapshot) (*Room, []Effect) { r := &Room{ term: term, @@ -84,10 +84,10 @@ func Restore(env Env, term uint32, s Snapshot) (*Room, []Effect) { return r, r.commit(b) } -// 返回的 effect 必须按顺序执行:Persist 失败时丢弃本批剩余的 effect 并放弃该房间, +// 返回的 effect 必须按顺序执行。Persist 失败时要丢弃这一批剩下的 effect,并放弃这个房间, // 否则客户端会看到没有落盘的状态。 -// effect 只能读,不能修改:同一批 effect 共用切片和指针,Reply.Err 指向包级的错误值。 -// 输出 Close 之后必须丢弃这个 Room:关闭时只结束正在播放的歌,不再开播下一首,它的状态已不是运行中房间的状态。 +// effect 只能读,不能修改,因为同一批 effect 共用切片和指针,Reply.Err 也指向包级的错误值。 +// 输出 Close 之后必须丢弃这个 Room。关闭时只结束正在播放的歌,不再开播下一首,所以 Room 的状态已经不是运行中房间的状态。 func (r *Room) Handle(env Env, in Input) []Effect { b := r.begin(env) switch in := in.(type) { @@ -115,7 +115,7 @@ func (r *Room) Handle(env Env, in Input) []Effect { case Timer: r.timer(b, in) } - // 入队、点赞和切换播放模式都会改变等待队列的顺序,各条规则不必各自排序。 + // 入队、点赞和切换播放模式都会改变等待队列的顺序。处理完统一排序,各条规则就不必各自排序。 r.sortWaiting() return r.commit(b) } @@ -154,7 +154,7 @@ func (b *batch) respond(id RequestID, res Result, err *Error) { } // 状态事件由处理前后的差异得出,不由各条规则各自发出,这样任何状态变化都不会漏掉广播。 -// Persist 排在最前:运行时落盘成功后才执行其余的 effect,客户端看到的都是已落盘的状态。 +// Persist 排在最前,运行时落盘成功后才执行其余的 effect,这样客户端看到的都是已落盘的状态。 func (r *Room) commit(b *batch) []Effect { after := r.snapshot() var out []Effect @@ -164,7 +164,7 @@ func (r *Room) commit(b *batch) []Effect { for _, p := range b.plays { out = append(out, p) } - // 排在广播之前:客户端看到开播时,听歌记录已经写好。 + // RecordListen 排在广播之前,这样客户端看到开播时,听歌记录已经写好。 if l, ok := r.listened(b); ok { out = append(out, l) } @@ -197,7 +197,7 @@ func (r *Room) commit(b *batch) []Effect { return append(out, b.tasks...) } -// 开播了一首新歌时记给所有在线成员;正在播放的歌没变时,只记给这一批之前没有会话的成员。 +// 开播了一首新歌时,听歌记录记给所有在线成员。正在播放的歌没变时,只记给这一批处理之前还没有会话的成员。 func (r *Room) listened(b *batch) (RecordListen, bool) { c := r.current if c == nil { diff --git a/internal/domain/room/testdata/listen.txtar b/internal/domain/room/testdata/listen.txtar index 14ff8f3..8622189 100644 --- a/internal/domain/room/testdata/listen.txtar +++ b/internal/domain/room/testdata/listen.txtar @@ -1,5 +1,5 @@ -听歌记录:开播时记给所有在线成员,同一成员的几个会话只记一次;没有在播的歌时进入不记;中途进入的成员只记自己; -同一成员的第二台设备上线不记;断线重连时新会话先于旧会话的离开到达,不记;离开之后再进入,按同一个开播时间再记一次。 +听歌记录:开播时记给所有在线成员,同一成员的几个会话只记一次。没有在播的歌时进入不记。中途进入的成员只记自己。 +同一成员的第二台设备上线不记。断线重连时新会话先于旧会话的离开到达,也不记。离开之后再进入,按同一个开播时间再记一次。 -- 01 -- > restore persistent -- 02 -- diff --git a/internal/domain/room/testdata/presence.txtar b/internal/domain/room/testdata/presence.txtar index 246484e..3fda977 100644 --- a/internal/domain/room/testdata/presence.txtar +++ b/internal/domain/room/testdata/presence.txtar @@ -1,4 +1,4 @@ -在线状态与聊天:会话加入、离开都广播在线列表;聊天照常广播,不落盘;临时房间最后一个会话离开 5 分钟后关闭,之前的计时作废。 +在线状态与聊天:会话加入、离开都广播在线列表。聊天照常广播,不落盘。临时房间最后一个会话离开 5 分钟后关闭,之前的计时作废。 -- 01 -- > restore ephemeral schedule 1 5m0s diff --git a/internal/domain/room/testdata/queue.txtar b/internal/domain/room/testdata/queue.txtar index 80a5e7e..b3f1bcf 100644 --- a/internal/domain/room/testdata/queue.txtar +++ b/internal/domain/room/testdata/queue.txtar @@ -1,4 +1,4 @@ -等待队列:点赞改变顺序,删除只针对等待队列;准备失败的条目移出队列,待播准备失败时选下一首。 +等待队列:点赞改变顺序,删除只针对等待队列。准备失败的条目移出队列,待播准备失败时选下一首。 -- 01 -- > restore persistent -- 02 -- diff --git a/internal/domain/room/testdata/rotation.txtar b/internal/domain/room/testdata/rotation.txtar index ec3b6bd..80d81a6 100644 --- a/internal/domain/room/testdata/rotation.txtar +++ b/internal/domain/room/testdata/rotation.txtar @@ -1,4 +1,4 @@ -轮流播放:点赞数相同时先比轮次,各点歌人轮流。轮次在入队时定下:新歌排进最早还没放完的一轮,点歌人已有歌时排到他最后一首的下一轮,之后删除、出队都不改变其余歌的顺序。甲靠后的一首被点赞先放时,新来的丙仍排进第 0 轮;第 0 轮都放完后,甲再点歌从第 1 轮开始。 +轮流播放:点赞数相同时先比轮次,各点歌人轮流。轮次在入队时定下。新歌排进最早还没放完的一轮,点歌人已有歌时排到他最后一首的下一轮。之后删除、出队都不改变其余歌的顺序。甲靠后的一首被点赞先放时,新来的丙仍排进第 0 轮。第 0 轮都放完后,甲再点歌从第 1 轮开始。 -- 01 -- > restore persistent -- 02 -- diff --git a/internal/domain/room/testdata/sequential.txtar b/internal/domain/room/testdata/sequential.txtar index e55b7f3..1174eb7 100644 --- a/internal/domain/room/testdata/sequential.txtar +++ b/internal/domain/room/testdata/sequential.txtar @@ -1,4 +1,4 @@ -顺序播放:点歌后准备,开播;播完接着播下一首;队列播空后进入空闲。 +顺序播放:点歌后准备,然后开播。播完接着播下一首。队列播空后进入空闲。 -- 01 -- > restore persistent -- 02 -- diff --git a/internal/domain/track/bilibili.go b/internal/domain/track/bilibili.go index d046226..86287f4 100644 --- a/internal/domain/track/bilibili.go +++ b/internal/domain/track/bilibili.go @@ -5,7 +5,7 @@ import ( "strconv" ) -// 同一个视频只能有一种 ID,否则会重复下载:第 1 P 不带序号,序号不带前导零。 +// 同一个视频只能有一种 ID,否则会重复下载。所以第 1 P 不带序号,序号也不带前导零。 var bilibiliID = regexp.MustCompile(`^(BV[1-9A-HJ-NP-Za-km-z]{10})(?:/([2-9]|[1-9][0-9]{1,3}))?$`) func BilibiliID(bvid string, page int) string { diff --git a/internal/domain/track/track.go b/internal/domain/track/track.go index 96fc8c9..04b57b1 100644 --- a/internal/domain/track/track.go +++ b/internal/domain/track/track.go @@ -20,8 +20,8 @@ type Track struct { ID string } -// LinkID 是链接作为曲目 ID 的写法,对象存储中的对象键也由它得出:只把 scheme 和主机名中的 ASCII 字母转成小写, -// 其余字节一律不变(片段、查询参数的顺序、端口和百分号编码都可能有意义)。非 ASCII 字符不转小写:国际化域名的大小写映射 +// LinkID 是链接作为曲目 ID 的写法,对象存储中的对象键也由 LinkID 得出。LinkID 只把 scheme 和主机名中的 ASCII 字母转成小写, +// 其余字节一律不变,因为片段、查询参数的顺序、端口和百分号编码都可能有意义。非 ASCII 字符不转小写,因为国际化域名的大小写映射 // 与 Unicode 的不同。link 必须是经过协议校验的链接。 func LinkID(link string) string { scheme, rest, _ := strings.Cut(link, "://") @@ -54,13 +54,13 @@ type Meta struct { Artist string Album string CoverURL string - // 链接曲目的封面在对象存储中:只存对象键,对外的地址由 serve 拼接,公开地址改变之后,已保存的曲目和条目仍然有效。 + // 链接曲目的封面在对象存储中。CoverKey 只存对象键,对外的地址由 serve 拼接,这样公开地址改变之后,已保存的曲目和条目仍然有效。 CoverKey ObjectKey PageURL string Duration time.Duration } -// 对象键不是地址:拼上对象存储的公开地址之后才能交给浏览器。 +// 对象键不是地址,拼上对象存储的公开地址之后才能交给浏览器。 type ObjectKey string type Prepared struct { diff --git a/internal/lib/logctx/logctx.go b/internal/lib/logctx/logctx.go index 0fe3217..646872d 100644 --- a/internal/lib/logctx/logctx.go +++ b/internal/lib/logctx/logctx.go @@ -35,8 +35,8 @@ func WithJob(ctx context.Context, id int64) context.Context { return with(ctx, func(f *fields) { f.job = id }) } -// 返回的 handler 自己记下 logger 打开的 group,写日志时才把它们交给 next,使关联字段写在日志的顶层而不落进 -// group 中(River 的 logger 带有 river group)。往日志中添加字段的其他 handler 要放在它的内层。 +// 返回的 handler 自己记下 logger 打开的 group,写日志时才把这些 group 交给 next。这样关联字段写在日志的顶层,不会落进 +// group 中(River 的 logger 带有 river group)。往日志中添加字段的其他 handler 要放在这个 handler 的内层。 func NewHandler(next slog.Handler) slog.Handler { return handler{next: next} } diff --git a/internal/lib/logctx/logctx_test.go b/internal/lib/logctx/logctx_test.go index d1615df..87d7c74 100644 --- a/internal/lib/logctx/logctx_test.go +++ b/internal/lib/logctx/logctx_test.go @@ -61,7 +61,7 @@ func TestFieldsStayAtTopLevelInGroups(t *testing.T) { func TestDerivedLoggersAreIndependent(t *testing.T) { var buf bytes.Buffer - // 逐个打开 group、逐个添加字段,记录它们的切片才会有多余的容量。 + // 逐个打开 group、逐个添加字段,记录 group 和字段的切片才会有多余的容量。 groups := slog.New(NewHandler(slog.NewJSONHandler(&buf, &slog.HandlerOptions{ReplaceAttr: withoutTime}))).WithGroup("a").WithGroup("b").WithGroup("c") attrs := groups.With("p", 1).With("q", 2).With("r", 3) for _, logger := range []*slog.Logger{groups.WithGroup("x"), groups.WithGroup("y"), attrs.With("s", "first"), attrs.With("s", "second"), attrs} { diff --git a/internal/lib/loudness/loudness.go b/internal/lib/loudness/loudness.go index 836da60..1c6e72e 100644 --- a/internal/lib/loudness/loudness.go +++ b/internal/lib/loudness/loudness.go @@ -22,10 +22,10 @@ type Result struct { Integrated float64 // 综合响度(LUFS),全静音时为 -Inf Peak float64 // 采样峰值(dBFS),全静音时为 -Inf Duration time.Duration - Damage slog.LogValuer // 解码时跳过或容错的异常,音频完好时为 nil;此时其余字段只反映解出的部分 + Damage slog.LogValuer // 解码时跳过或容错的异常,音频完好时为 nil。不为 nil 时,其余字段只反映解出的部分 } -// target 的单位是 LUFS,返回的增益单位是 dB;静音时返回 0。 +// target 的单位是 LUFS,返回的增益单位是 dB。静音时返回 0。 func (r *Result) GainTo(target float64) float64 { if math.IsInf(r.Integrated, -1) { return 0 @@ -87,12 +87,12 @@ func AnalyzeSamples(samples []float64, sampleRate, channels int) (*Result, error // blockSegs 是一个 400ms 响度块包含的 100ms 段数(BS.1770:400ms 块,75% 重叠)。 const blockSegs = 4 -// Analyzer 是流式的 BS.1770 响度分析器:只保留滤波器状态与滚动的块能量,整段音频无需驻留内存。 +// Analyzer 是流式的 BS.1770 响度分析器。它不保存采样,只保留滤波器状态、最近 4 个 100ms 段的能量和每个 400ms 块的能量,所以整段音频无需驻留内存。 type Analyzer struct { sampleRate int channels int - // 浏览器把单声道同时放到左右两个声道,所以单声道按双单声道(libebur128 的 DUAL_MONO)计算: - // 能量是单声道的 2 倍(+3.01 LU),峰值不变。其余声道布局的权重为 1。 + // 浏览器把单声道同时放到左右两个声道,所以单声道按双单声道(libebur128 的 DUAL_MONO)计算。 + // 按双单声道算,能量是单声道的 2 倍(+3.01 LU),峰值不变。其余声道布局的权重为 1。 weight float64 segSize int // 100ms 段的帧数 filters [][2]biquadState @@ -148,7 +148,7 @@ func (a *Analyzer) Write(samples []float64) { } } -// writeStereoSIMD 逐段调用汇编内核:IIR 在时间维上必须串行,并行的是 L、R 两个声道。 +// writeStereoSIMD 逐段调用汇编内核。IIR 在时间维上必须串行,所以并行的是 L、R 两个声道。 func (a *Analyzer) writeStereoSIMD(samples []float64) { for len(samples) > 0 { if a.nextCh == 1 || len(samples) < 2 { @@ -258,7 +258,7 @@ func gateBlocks(energies []float64) (float64, bool) { if n == 0 { return 0, false } - // 相对门限是均值响度以下 10 LU,即均值能量的 1/10;与绝对门限取大,保证两级门限同时生效。 + // 相对门限是均值响度以下 10 LU,即均值能量的 1/10。再与绝对门限取大,保证两级门限同时生效。 sum, n = sumAbove(energies, max(absGate, sum/float64(n)/10)) if n == 0 { return 0, false diff --git a/internal/lib/loudness/loudness_amd64.go b/internal/lib/loudness/loudness_amd64.go index be38389..aaabfe6 100644 --- a/internal/lib/loudness/loudness_amd64.go +++ b/internal/lib/loudness/loudness_amd64.go @@ -6,8 +6,8 @@ import "golang.org/x/sys/cpu" var hasAVX2 = cpu.X86.HasAVX2 -// stereoProcessAVX2 处理双声道交错采样:每帧更新 L、R 声道的峰值与两级 K 加权 biquad 状态, -// 滤波后的能量累加进 sums。samples 的长度必须为偶数,st 的内存布局见 loudness_amd64.s。 +// stereoProcessAVX2 处理双声道交错采样。每帧更新两个声道共用的峰值 peak,以及 L、R 声道各自的两级 K 加权 biquad 状态, +// 滤波后的能量按声道累加进 sums。samples 的长度必须为偶数,st 的内存布局见 loudness_amd64.s。 // //go:noescape func stereoProcessAVX2(samples []float64, st *[2][2]biquadState, sums *[2]float64, peak *float64) diff --git a/internal/lib/loudness/loudness_amd64.s b/internal/lib/loudness/loudness_amd64.s index 1fd6c44..fb902c0 100644 --- a/internal/lib/loudness/loudness_amd64.s +++ b/internal/lib/loudness/loudness_amd64.s @@ -2,9 +2,9 @@ #include "textflag.h" -// st(*[2][2]biquadState)的内存布局:每个 biquadState 占 72 字节, -// {b0=0 b1=8 b2=16 a1=24 a2=32 x1=40 x2=48 y1=56 y2=64}; -// L 搁架=0,L 高通=72,R 搁架=144,R 高通=216。 +// st(*[2][2]biquadState)的内存布局:每个 biquadState 占 72 字节,各字段的偏移是 +// {b0=0 b1=8 b2=16 a1=24 a2=32 x1=40 x2=48 y1=56 y2=64}。 +// 各 biquadState 的起始偏移是 L 搁架=0,L 高通=72,R 搁架=144,R 高通=216。 // L、R 的同名字段相距 144 字节,用 VMOVSD 和 VMOVHPD 组合成 [L, R] 向量。 // // 寄存器分配(128 位 VEX,lane0 为 L,lane1 为 R): diff --git a/internal/lib/loudness/loudness_test.go b/internal/lib/loudness/loudness_test.go index 6a8a188..135ddb5 100644 --- a/internal/lib/loudness/loudness_test.go +++ b/internal/lib/loudness/loudness_test.go @@ -44,8 +44,8 @@ func sine(sampleRate int, freq, amp, seconds float64) []float64 { func TestAnalyzeSamplesSine(t *testing.T) { const fs = 48000 - // 997 Hz、振幅 0.25:原始 RMS 对应约 -12.73 LUFS, - // K 加权在 1 kHz 附近约有 +0.6 dB 增益,预期约 -12.1 LUFS。 + // 997 Hz、振幅 0.25 的正弦波,原始 RMS 对应约 -12.73 LUFS。 + // K 加权在 1 kHz 附近约有 +0.6 dB 增益,所以预期约 -12.1 LUFS。 res, err := AnalyzeSamples(sine(fs, 997, 0.25, 3), fs, 2) if err != nil { t.Fatal(err) @@ -91,7 +91,7 @@ func TestAnalyzeSamplesMonoIsDualMono(t *testing.T) { if math.Abs(got.Integrated-want.Integrated) > 1e-9 || got.Peak != want.Peak || got.Duration != want.Duration { t.Errorf("单声道 = %+v,期望与双声道相同:%+v", *got, *want) } - // 振幅 0.25 的正弦波每个声道的均方是 0.03125,左右声道之和 0.0625 即 -12.04 dB;997 Hz 处 K 加权的增益与 -0.691 的修正基本抵消。 + // 振幅 0.25 的正弦波每个声道的均方是 0.03125,左右声道之和 0.0625 即 -12.04 dB。997 Hz 处 K 加权的增益与 -0.691 的修正基本抵消。 if math.Abs(got.Integrated-(-12.04)) > 0.1 { t.Errorf("Integrated = %.2f LUFS,期望约 -12.04", got.Integrated) } @@ -99,9 +99,9 @@ func TestAnalyzeSamplesMonoIsDualMono(t *testing.T) { } } -// 单声道的权重在门控之前计入,与 libebur128 的 DUAL_MONO 一致。997 Hz 的正弦波振幅为 a 时,按单声道算约 20·log10(a) - 3.01 LUFS, -// 振幅 3.55e-4 的安静段是 -72 LUFS,低于 -70 LUFS 的绝对门限;按双单声道算是 -69 LUFS,要参与综合响度。 -// 权重放到门控之后,这一段会被绝对门限丢掉,结果与复制成左右声道的立体声不同。 +// 单声道的权重在门控之前计入,与 libebur128 的 DUAL_MONO 一致。997 Hz 的正弦波振幅为 a 时,按单声道算约 20·log10(a) - 3.01 LUFS。 +// 所以振幅 3.55e-4 的安静段按单声道算是 -72 LUFS,低于 -70 LUFS 的绝对门限。按双单声道算是 -69 LUFS,要参与综合响度。 +// 如果权重放到门控之后,这一段会被绝对门限丢掉,结果就与复制成左右声道的立体声不同。 func TestAnalyzeSamplesMonoWeightsBeforeGating(t *testing.T) { const fs, quiet = 48000, 3.55e-4 for _, tt := range []struct { @@ -109,7 +109,7 @@ func TestAnalyzeSamplesMonoWeightsBeforeGating(t *testing.T) { dual []float64 want float64 // LUFS }{ - // 安静段(-69 LUFS)之后是响 6 dB 的一段(-63 LUFS),两段等长:能量平均约 -65 LUFS,安静段在相对门限之上。 + // 安静段(-69 LUFS)之后是响 6 dB 的一段(-63 LUFS),两段等长。能量平均约 -65 LUFS,安静段在相对门限之上。 {"安静段和较响的一段", append(sine(fs, 997, quiet, 2), sine(fs, 997, 2*quiet, 2)...), -65}, {"不足 400ms 的安静段", sine(fs, 997, quiet, 0.3), -69}, } { @@ -199,7 +199,7 @@ func TestSpotifyGain(t *testing.T) { want float64 }{ {"正常提升", Result{Integrated: -20, Peak: -10}, 6}, - // 响度 -20 LUFS、峰值 -2 dBFS:增益 +6 dB 会使峰值达到 +4 dBFS,被限制到 -1 - (-2) = +1 dB。 + // 响度 -20 LUFS、峰值 -2 dBFS 时,+6 dB 的增益会使峰值达到 +4 dBFS,所以增益被限制到 -1 - (-2) = +1 dB。 {"峰值限制", Result{Integrated: -20, Peak: -2}, 1}, {"需要降低", Result{Integrated: -10, Peak: -6}, -4}, } @@ -216,7 +216,7 @@ func TestCoefficient(t *testing.T) { if c := r.Coefficient(); math.Abs(c-1.9953) > 0.001 { t.Errorf("Coefficient = %v, 预期约 1.995", c) } - // 响度 -8 LUFS 高于目标:-14 - (-8) = -6 dB,系数约 0.501。 + // 响度 -8 LUFS 高于目标,增益是 -14 - (-8) = -6 dB,系数约 0.501。 r = &Result{Integrated: -8, Peak: -3} if c := r.Coefficient(); math.Abs(c-0.5012) > 0.001 { t.Errorf("Coefficient = %v, 预期约 0.501", c) diff --git a/internal/lib/loudness/mp3_test.go b/internal/lib/loudness/mp3_test.go index 7747c03..84781fe 100644 --- a/internal/lib/loudness/mp3_test.go +++ b/internal/lib/loudness/mp3_test.go @@ -15,13 +15,13 @@ import ( // ffmpeg -nostdin -loglevel error -f lavfi -i 'anoisesrc=color=pink:amplitude=0.25:sample_rate=22050:duration=3:seed=7' -c:a libmp3lame -b:a 32k -fflags +bitexact -map_metadata -1 pink-22k05-32k-mono.mp3 // ffmpeg -nostdin -loglevel error -f lavfi -i "aevalsrc='0.2*sin(2*PI*220*t)+0.1*sin(2*PI*3300*t)|0.15*sin(2*PI*330*t)+0.12*sin(2*PI*2500*t)':s=22050:d=3" -c:a libmp3lame -b:a 32k -fflags +bitexact -map_metadata -1 tones-22k05-32k-stereo.mp3 // -// 参考值是 ffmpeg 解码同一个文件得到的采样(单声道复制成左右声道)交给 AnalyzeSamples 的结果,采样用 -// ffmpeg -nostdin -loglevel error -i 文件 -f f64le - 取得。ffmpeg n9.0.2 生成的文件与仓库中的逐字节相同; -// 其他版本生成的字节可能不同,参考值要按新文件重新计算。 +// 参考值是这样算的:用 ffmpeg 解码同一个文件,把得到的采样(单声道复制成左右声道)交给 AnalyzeSamples。采样用 +// ffmpeg -nostdin -loglevel error -i 文件 -f f64le - 取得。ffmpeg n9.0.2 生成的文件与仓库中的逐字节相同。 +// 其他版本生成的字节可能不同,字节变了,参考值就要按新文件重新计算。 // // 22.05 kHz 的低码率 MP3 解码出错时,采样会削波,响度偏高几 dB,所以要核对响度和峰值,不能只看能否解码。 -// 容差 0.05 dB:gl3d 与 ffmpeg 解码出的采样只差浮点误差,容差留给其他平台上乘加融合造成的差异。 -// 时长正好是 3 秒:Info 帧里的 LAME 标签有效,gl3d 把编码器在两端加的延迟和填充裁掉了,这条断言也固定了裁剪。 +// 容差取 0.05 dB。gl3d 与 ffmpeg 解码出的采样只差浮点误差,这个容差留给其他平台上乘加融合造成的差异。 +// 时长正好是 3 秒,因为 Info 帧里的 LAME 标签有效,gl3d 把编码器在两端加的延迟和填充裁掉了。时长的断言也把这一裁剪固定了下来。 func TestAnalyzeReaderMP3MatchesReference(t *testing.T) { tests := []struct { file string diff --git a/internal/lib/wav/decode.go b/internal/lib/wav/decode.go index 601e91f..0a86bea 100644 --- a/internal/lib/wav/decode.go +++ b/internal/lib/wav/decode.go @@ -39,9 +39,9 @@ const ( formatExtensible = 0xFFFE ) -// Decoder 按块返回按声道交错的 PCM 采样,满刻度为 1;MP3 和浮点 WAV 的采样可以超出 [-1, 1]。 +// Decoder 按块返回按声道交错的 PCM 采样,满刻度为 1。MP3 和浮点 WAV 的采样可以超出 [-1, 1]。 type Decoder interface { - // Read 解码并填充 p,返回采样个数;音频解码完毕时返回 io.EOF。p 至少要能容纳一个采样帧(Channels() 个采样)。 + // Read 解码并填充 p,返回采样个数。音频解码完毕时返回 io.EOF。p 至少要能容纳一个采样帧(Channels() 个采样)。 Read(p []float64) (int, error) SampleRate() int Channels() int @@ -254,7 +254,7 @@ func parseWAVHeader(r io.Reader) (wavHeader, error) { if size < 16 { return wavHeader{}, errors.New("wav: fmt 块过短") } - // 块的长度来自文件,不能按它分配内存:只读用到的字节(最长是 WAVE_FORMAT_EXTENSIBLE 的 40 字节),其余的丢弃。 + // 块的长度来自文件,不能按这个长度分配内存。所以只读用到的字节(最长是 WAVE_FORMAT_EXTENSIBLE 的 40 字节),其余的丢弃。 var buf [40]byte used := min(size, int64(len(buf))) if _, err := io.ReadFull(r, buf[:used]); err != nil { @@ -321,7 +321,7 @@ func newFLACDecoder(r io.Reader) (Decoder, error) { var decoded uint64 d.fill = func() error { for { - // QQ 音乐的 FLAC 在音频之后还有数据:继续读会被当成音频帧报错,整首的分析结果都会作废。NSamples 为 0 表示文件没有声明总数。 + // QQ 音乐的 FLAC 在音频之后还有数据。继续读的话,这些数据会被当成音频帧而报错,整首的分析结果都会作废。NSamples 为 0 表示文件没有声明总数。 if info.NSamples > 0 && decoded >= info.NSamples { return io.EOF } diff --git a/internal/lib/wav/decode_test.go b/internal/lib/wav/decode_test.go index 86bddcf..6b78cc5 100644 --- a/internal/lib/wav/decode_test.go +++ b/internal/lib/wav/decode_test.go @@ -168,9 +168,9 @@ func TestDecodeDoesNotAllocateByDeclaredChunkSize(t *testing.T) { // ffmpeg -nostdin -loglevel error -f lavfi -i 'sine=frequency=1000:sample_rate=22050:duration=0.5' -c:a libmp3lame -q:a 5 -fflags +bitexact -map_metadata -1 sine-mpeg2.mp3 // ffmpeg -nostdin -loglevel error -f lavfi -i 'sine=frequency=1000:sample_rate=8000:duration=0.5' -c:a libmp3lame -q:a 5 -fflags +bitexact -map_metadata -1 sine-mpeg25.mp3 // -// ffmpeg n9.0.2 生成的文件与仓库中的逐字节相同;其他版本生成的字节可能不同,测试只检查解码出的采样率、声道数、时长、 -// 峰值和半周期数。MP3 编码器在两端加了静音:Info 帧里的 LAME 标签有效,gl3d 把它们裁掉, -// 所以时长正好是 0.5 秒,这条断言也固定了裁剪。 +// ffmpeg n9.0.2 生成的文件与仓库中的逐字节相同。其他版本生成的字节可能不同,所以测试只检查解码出的采样率、声道数、时长、 +// 峰值和半周期数。MP3 编码器在两端加了静音。Info 帧里的 LAME 标签有效,gl3d 会把这些静音裁掉, +// 所以时长正好是 0.5 秒,时长的断言也把这一裁剪固定了下来。 func TestDecodeCompressed(t *testing.T) { tests := []struct { file string @@ -220,7 +220,7 @@ func TestDecodeCompressed(t *testing.T) { } } -// 417 字节是 128 kbps、44.1 kHz 的帧长。帧头之后全是零:Layer III 的这样一帧解码出 1152 个采样帧的静音。 +// 417 字节是 128 kbps、44.1 kHz 的帧长。帧头之后全是零,这样的 Layer III 帧会解码出 1152 个采样帧的静音。 func mp3Frame(header ...byte) []byte { return append(header, make([]byte, 417-len(header))...) } diff --git a/internal/lib/wav/mp3.go b/internal/lib/wav/mp3.go index 43e08da..a96670d 100644 --- a/internal/lib/wav/mp3.go +++ b/internal/lib/wav/mp3.go @@ -13,10 +13,10 @@ import ( // gl3d 的 ReadPCM 按采样帧计数,而 Decoder.Read 按采样个数计数。 type mp3Decoder struct{ *gl3d.Decoder } -// ctx 传 Background:取消靠 r 在 ctx 结束后读取失败(下载流,以及 prepare 中包了 ctx 的文件),gl3d 会原样返回这个错误; +// ctx 传 Background。取消靠的是 ctx 结束后 r 读取失败(下载流,以及 prepare 中包了 ctx 的文件),gl3d 会原样返回这个错误。 // WAV 和 FLAC 也是这样取消的,所以 Decoder 不接收 ctx。gl3d 自己的 ctx 只在每帧之前和每次读取之前检查, // 即使传入真实的 ctx,也至多少解码已缓冲的 64 KiB。 -// 用默认的宽松模式:解码只用来计算增益和时长,播放的是原文件,局部损坏时按解出的部分计算比整首放弃更接近实际。 +// 用默认的宽松模式,因为解码只用来计算增益和时长,播放的是原文件。局部损坏时,按解出的部分计算比整首放弃更接近实际。 func newMP3Decoder(r io.Reader) (Decoder, error) { dec, err := gl3d.NewDecoder(context.Background(), r) if err != nil { @@ -30,7 +30,7 @@ func (d mp3Decoder) Read(p []float64) (int, error) { return n * d.Channels(), mp3Error(err) } -// Damage 返回解码时跳过或容错的异常,用于写入日志;音频完好时返回 nil。解码结束后才是最终结果。 +// Damage 返回解码时跳过或容错的异常,用于写入日志。音频完好时返回 nil。解码结束后,Damage 的返回值才是最终结果。 func Damage(d Decoder) slog.LogValuer { mp3, ok := d.(mp3Decoder) if !ok { @@ -43,7 +43,7 @@ func Damage(d Decoder) slog.LogValuer { } // io.EOF 原样返回,调用方靠它判断读完。 -// 每次 Read 都经过这里:errors.As 的目标变量要在提前返回之后声明,否则它们会逃逸到堆上,使每次 Read 多分配四个对象。 +// 每次 Read 都经过这里:errors.As 的目标变量要在提前返回之后声明,否则它们会逃逸到堆上,使每次 Read 多分配两个对象。 func mp3Error(err error) error { if err == nil || err == io.EOF { return err diff --git a/protocol/decode.go b/protocol/decode.go index 0d3450c..05c263b 100644 --- a/protocol/decode.go +++ b/protocol/decode.go @@ -88,7 +88,7 @@ func decodeInteger(raw json.RawMessage, path string) (int64, error) { return n, nil } -// 先数元素再逐个解码:过长的数组不必解码就能拒绝。 +// 先数元素再逐个解码,这样过长的数组不必解码就能拒绝。 func decodeArray[T any](decode func(json.RawMessage, string) (T, error), maxItems int) func(json.RawMessage, string) ([]T, error) { return func(raw json.RawMessage, path string) ([]T, error) { var elems []json.RawMessage @@ -171,14 +171,14 @@ var ( numericLabel = regexp.MustCompile(`^(?i:[0-9]+|0x[0-9a-f]*)$`) ) -// x-url:scheme 为 http 或 https(不区分大小写)的绝对地址。浏览器的 new URL() 与 url.Parse 对一些写法的判断不同, +// x-url:scheme 为 http 或 https(不区分大小写)的绝对地址。浏览器的 new URL() 与 url.Parse 对一些写法的判断不同。 // 网页生成的校验函数按同样的规则检查,两边的结果才一致(protocol/testdata 中的用例由两边的测试共用): -// - 不含控制字符、空格和反斜杠(浏览器把 \ 当作 /),不含用户信息; -// - 主机名只由字母、数字、-、.、_、~ 和非 ASCII 字符组成,或者是方括号中的 IPv6 地址(浏览器会解码主机名中的 % 编码); -// - 最后一段是数字(或以 0x 开头)的主机名会被浏览器按 IPv4 地址解析,所以必须是规范的 IPv4 地址; +// - 不含控制字符、空格和反斜杠(浏览器把 \ 当作 /),不含用户信息。 +// - 主机名只由字母、数字、-、.、_、~ 和非 ASCII 字符组成,或者是方括号中的 IPv6 地址(浏览器会解码主机名中的 % 编码)。 +// - 最后一段是数字(或以 0x 开头)的主机名会被浏览器按 IPv4 地址解析,所以必须是规范的 IPv4 地址。 // - 端口不超过 65535,路径和片段中的 % 后面必须是两位十六进制数(url.Parse 不接受其他写法,浏览器却照样接受)。 // -// 国际化域名(主机名含非 ASCII 字符,或者有以 xn-- 开头的标签)只由浏览器检查:不合法时浏览器拒绝而这里接受,到准备时才会下载失败。 +// 国际化域名(主机名含非 ASCII 字符,或者有以 xn-- 开头的标签)只由浏览器检查。不合法时浏览器拒绝,这里却接受,到准备时才会下载失败。 func validURL(s string) bool { if strings.ContainsFunc(s, func(r rune) bool { return r <= ' ' || r == 0x7f || r == '\\' }) { return false diff --git a/protocol/http.go b/protocol/http.go index c645baa..dc0b92a 100644 --- a/protocol/http.go +++ b/protocol/http.go @@ -24,7 +24,7 @@ type Route struct { Location bool } -// 房间内请求的 HTTP 绑定。路径参数是请求中同名的字段;GET 和 DELETE 的其余字段来自查询串,其他方法的来自 JSON 请求体。 +// 房间内请求的 HTTP 绑定。路径参数是请求中同名的字段。GET 和 DELETE 的其余字段来自查询串,其他方法的其余字段来自 JSON 请求体。 // 组装出的请求交给与 WebSocket 相同的解码函数校验。 type RoomRoute struct { Route @@ -42,7 +42,7 @@ const ( queryInteger ) -// 空的请求体按 {} 处理:没有字段的请求可以不带请求体。 +// 空的请求体按 {} 处理,所以没有字段的请求可以不带请求体。 func (r RoomRoute) Decode(pathValue func(string) string, rawQuery string, body []byte) (any, error) { query, err := url.ParseQuery(rawQuery) if err != nil { diff --git a/protocol/idl.json b/protocol/idl.json index 11391e8..8a39dbd 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -80,7 +80,7 @@ "additionalProperties": false, "properties": { "source": {"$ref": "#/$defs/Source"}, - "id": {"type": "string", "description": "来源为 link 时是链接,响应和事件中是规范化之后的链接(scheme 和主机名转成小写);来源为 bilibili 时是 BV 号,或从第 2 P 起的 BV 号/分P序号"} + "id": {"type": "string", "description": "来源为 link 时是链接,响应和事件中是规范化之后的链接(scheme 和主机名转成小写)。来源为 bilibili 时,第 1 P 是 BV 号,从第 2 P 起是 BV 号/分P序号"} }, "required": ["source", "id"], "allOf": [ @@ -187,8 +187,8 @@ "trackCount": {"type": "integer"}, "createdAtMs": {"type": "integer"}, "updatedAtMs": {"type": "integer"}, - "coverUrl": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "歌单里排在最前、有封面的那首歌的封面;没有时为 null"}, - "shareId": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "分享链接 /s/ 中的 ID;没有分享时为 null"} + "coverUrl": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "歌单里排在最前、有封面的那首歌的封面。歌单里没有带封面的歌时为 null"}, + "shareId": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "分享链接 /s/ 中的 ID,没有分享时为 null"} }, "required": ["id", "name", "favorites", "trackCount", "createdAtMs", "updatedAtMs", "coverUrl", "shareId"] }, @@ -297,7 +297,7 @@ "type": "object", "additionalProperties": false, "properties": { - "key": {"type": "string", "description": "常驻房间的 API Key,只在生成或重置时返回这一次:bot 用 Authorization: Bearer 调用 /api/room 下的接口"} + "key": {"type": "string", "description": "常驻房间的 API Key,只在生成或重置时返回这一次。bot 用 Authorization: Bearer 调用 /api/room 下的接口"} }, "required": ["key"] }, @@ -305,7 +305,7 @@ "type": "object", "additionalProperties": false, "properties": { - "key": {"type": "string", "description": "曲目 ID 的 SHA-256(十六进制),用于删除;链接曲目的 ID 是规范化之后的链接"}, + "key": {"type": "string", "description": "曲目 ID 的 SHA-256(十六进制),用于删除。链接曲目的 ID 是规范化之后的链接"}, "source": {"$ref": "#/$defs/Source"}, "link": {"type": "string", "description": "这首歌的页面:链接曲目是链接本身,B 站的歌是视频页面"}, "title": {"type": "string"}, @@ -319,7 +319,7 @@ }, "MediaSort": { "type": "string", - "description": "stored:存储时间从新到旧;idle:最近播放从早到晚,从没播放过的排最前;size:占用从大到小。三种排序都以曲目 ID 作为最后的排序键,翻页时不重复也不遗漏", + "description": "stored:存储时间从新到旧。idle:最近播放从早到晚,从没播放过的排最前。size:占用从大到小。三种排序都以曲目 ID 作为最后的排序键,所以翻页时不重复也不遗漏", "enum": ["stored", "idle", "size"] }, "StoredMediaRef": { @@ -337,7 +337,7 @@ "id": {"type": "string", "description": "音源平台的账号 ID,用字符串避免大整数精度丢失"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "账号昵称,未知时为 null"}, "vip": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "description": "会员状态,未知时为 null"}, - "verified": {"type": "boolean", "description": "是否由上游实时查询确认;仅从本地凭据识别时为 false"} + "verified": {"type": "boolean", "description": "是否由上游实时查询确认,仅从本地凭据识别时为 false"} }, "required": ["id", "name", "vip", "verified"] }, @@ -350,7 +350,7 @@ "type": "object", "additionalProperties": false, "properties": { - "key": {"type": "string", "description": "查询扫码结果时原样带上,只放在请求体中:有效期内它相当于这次登录的凭证"}, + "key": {"type": "string", "description": "查询扫码结果时原样带上。有效期内 key 相当于这次登录的凭证,所以只放在请求体中"}, "qrImage": {"type": "string", "description": "可以直接显示的二维码图片:data: URI 或 https 地址"}, "expiresAtMs": {"type": "integer"} }, @@ -388,7 +388,7 @@ "favorites": {"type": "boolean", "description": "是否是“我喜欢”"}, "ownerName": {"type": "string", "description": "所有者的昵称"}, "ownerAvatarHash": {"type": "string", "description": "所有者邮箱的 MD5,未设置邮箱时为空字符串"}, - "items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylistItem"}, "description": "新加入的在前"} + "items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylistItem"}, "description": "顺序与所有者在 listMyPlaylistItems 里看到的相同"} }, "required": ["name", "favorites", "ownerName", "ownerAvatarHash", "items"] }, @@ -399,12 +399,12 @@ }, "GitHubIntent": { "type": "string", - "description": "login:游客用 GitHub 登录,GitHub 账号没有绑定过时就是注册;bind:已注册的账号绑定 GitHub;verify:只绑定了 GitHub 的账号补设用户名和密码之前,用绑定的 GitHub 确认身份", + "description": "login:游客用 GitHub 登录,GitHub 账号没有绑定过时就是注册。bind:已注册的账号绑定 GitHub。verify:只绑定了 GitHub 的账号补设用户名和密码之前,用绑定的 GitHub 确认身份", "enum": ["login", "bind", "verify"] }, "GitHubClient": { "type": "string", - "description": "发起 GitHub 授权的客户端,决定授权结束之后跳到哪里。web:网页的 /auth/github;app:App 用系统浏览器授权,授权结束之后跳到 moe.dihe.alisten:/auth/github,回到 App", + "description": "发起 GitHub 授权的客户端,决定授权结束之后跳到哪里。web:跳到网页的 /auth/github。app:App 用系统浏览器授权,授权结束之后跳到 moe.dihe.alisten:/auth/github,回到 App", "enum": ["web", "app"] } }, @@ -749,7 +749,7 @@ }, { "type": "room.activity", - "description": "点歌、移除、点赞、投票、投票切歌成功和准备失败;不同 kind 只填写相关字段", + "description": "点歌、移除、点赞、投票、投票切歌成功和准备失败。不同 kind 只填写各自相关的字段", "data": { "type": "object", "additionalProperties": false, @@ -786,7 +786,7 @@ }, { "name": "getMe", - "description": "当前成员的资料;已注册的成员带上账号", + "description": "当前成员的资料。已注册的成员还带上账号", "method": "GET", "path": "/api/me", "auth": "member", @@ -803,7 +803,7 @@ }, { "name": "updateMe", - "description": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除", + "description": "修改昵称和头像。email 用来计算头像哈希,已注册的账号还会同时保存 email。email 去掉首尾空白后应形如 name@example.com,空字符串表示清除", "method": "PATCH", "path": "/api/me", "auth": "member", @@ -826,7 +826,7 @@ }, { "name": "register", - "description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制,但发出请求的成员 token 要在 10 分钟内用绑定的 GitHub 确认过身份(startGithubLogin 的 verify),没有确认时返回 FORBIDDEN:拿到成员 token 的人不能抢先设成自己的密码。补设成功之后这次确认作废,账号在其他设备上的成员 token 随即失效,与修改密码相同;失败时确认仍然有效", + "description": "游客注册账号,设置用户名和密码,成员和成员 token 都不变。站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用 register 补设用户名和密码,不受注册开关限制。但发出请求的成员 token 要在 10 分钟内用绑定的 GitHub 确认过身份(startGithubLogin 的 verify),没有确认时返回 FORBIDDEN。这样拿到成员 token 的人就不能抢先设成自己的密码。补设成功之后这次确认作废,账号在其他设备上的成员 token 随即失效,这与修改密码相同。补设失败时确认仍然有效", "method": "POST", "path": "/api/account", "auth": "member", @@ -851,7 +851,7 @@ }, { "name": "changePassword", - "description": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS,账号还没有设置密码时返回 CONFLICT", + "description": "修改密码。账号在其他设备上的成员 token 随即失效,发出请求的成员 token 不变。当前密码错误时返回 INVALID_CREDENTIALS,账号还没有设置密码时返回 CONFLICT", "method": "PUT", "path": "/api/account/password", "auth": "member", @@ -868,7 +868,7 @@ }, { "name": "login", - "description": "游客登录账号:返回账号的新成员 token,发出请求的游客 token 随即失效;用户名或密码错误时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT", + "description": "游客登录账号,返回账号的新成员 token,发出请求的游客 token 随即失效。用户名或密码错误时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT", "method": "POST", "path": "/api/sessions", "auth": "member", @@ -879,7 +879,7 @@ "properties": { "username": {"$ref": "#/$defs/Username"}, "password": {"$ref": "#/$defs/AccountPassword"}, - "device": {"$ref": "#/$defs/LoginDevice", "description": "本机此前登录这个账号时拿到的凭证:有人反复猜这个账号的密码时,带上它本机仍然能登录"} + "device": {"$ref": "#/$defs/LoginDevice", "description": "本机此前登录这个账号时拿到的凭证。有人反复猜这个账号的密码时,本机带上这个凭证仍然能登录"} }, "required": ["username", "password"] }, @@ -890,7 +890,7 @@ "memberToken": {"type": "string"}, "member": {"$ref": "#/$defs/Member"}, "account": {"$ref": "#/$defs/Account"}, - "device": {"$ref": "#/$defs/LoginDevice", "description": "本机以后登录这个账号时要带上的凭证,退出登录之后也要保留:带来的凭证有效时原样返回,否则是新签发的"} + "device": {"$ref": "#/$defs/LoginDevice", "description": "本机以后登录这个账号时要带上的凭证,退出登录之后也要保留。带来的凭证有效时原样返回,否则返回新签发的凭证"} }, "required": ["memberToken", "member", "account", "device"] } @@ -905,7 +905,7 @@ }, { "name": "listMyPlaylists", - "description": "我的歌单:“我喜欢”在最前,其余按创建的先后;只有已注册的账号能使用歌单", + "description": "我的歌单:“我喜欢”在最前,其余按创建的先后排列。只有已注册的账号能使用歌单", "method": "GET", "path": "/api/me/playlists", "auth": "member", @@ -937,7 +937,7 @@ }, { "name": "renameMyPlaylist", - "description": "重命名歌单;“我喜欢”不能重命名", + "description": "重命名歌单。“我喜欢”不能重命名", "method": "PATCH", "path": "/api/me/playlists/{id}", "auth": "member", @@ -953,7 +953,7 @@ }, { "name": "deleteMyPlaylist", - "description": "删除歌单;“我喜欢”不能删除", + "description": "删除歌单。“我喜欢”不能删除", "method": "DELETE", "path": "/api/me/playlists/{id}", "auth": "member", @@ -961,7 +961,7 @@ }, { "name": "listMyPlaylistItems", - "description": "歌单里的歌,新加入的在前", + "description": "歌单里的歌,按歌单里的顺序排列。新加入的歌排在最前,moveMyPlaylistItems 可以调整顺序", "method": "GET", "path": "/api/me/playlists/{id}/items", "auth": "member", @@ -976,7 +976,7 @@ }, { "name": "addMyPlaylistItem", - "description": "把一首歌加入歌单:元数据由服务端查询,链接曲目要先准备好;已经在歌单里时不变;一个歌单最多 1000 首", + "description": "把一首歌加入歌单,元数据由服务端查询。链接曲目要先准备好。歌已经在歌单里时,歌单不变。一个歌单最多 1000 首", "method": "POST", "path": "/api/me/playlists/{id}/items", "auth": "member", @@ -1000,7 +1000,7 @@ }, { "name": "removeMyPlaylistItems", - "description": "把若干首歌移出歌单,一次 1 到 1000 首;不在歌单里的忽略", + "description": "把若干首歌移出歌单,一次 1 到 1000 首。不在歌单里的歌忽略", "method": "POST", "path": "/api/me/playlists/{id}/removals", "auth": "member", @@ -1016,7 +1016,7 @@ }, { "name": "copyMyPlaylistItems", - "description": "把另一个歌单里的若干首歌连同元数据复制到这个歌单,一次 1 到 1000 首;不在来源歌单里的和已在这个歌单里的跳过;新复制的排在最前,保持 tracks 的先后;装不下时整批拒绝;返回实际复制的数量", + "description": "把另一个歌单里的若干首歌连同元数据复制到这个歌单,一次 1 到 1000 首。不在来源歌单里的歌和已经在这个歌单里的歌跳过。新复制的歌排在最前,彼此之间保持 tracks 里的先后。装不下时整批拒绝。返回实际复制的数量", "method": "POST", "path": "/api/me/playlists/{id}/copies", "auth": "member", @@ -1040,7 +1040,7 @@ }, { "name": "moveMyPlaylistItems", - "description": "调整歌单里的顺序:把 tracks 里在歌单中的歌按给出的先后放到 before 之前,不给 before 就放到最后;一次 1 到 1000 首;before 不能在 tracks 里,也必须还在歌单里", + "description": "调整歌单里的顺序:把 tracks 里在歌单中的歌按给出的先后放到 before 之前,不给 before 就放到最后。一次 1 到 1000 首。before 不能在 tracks 里,也必须还在歌单里", "method": "POST", "path": "/api/me/playlists/{id}/moves", "auth": "member", @@ -1080,7 +1080,7 @@ }, { "name": "importMyPlaylist", - "description": "把导出的歌单文件里的歌导入成一个新歌单,歌单名不合法时用“导入的歌单”;只收前 1000 首,重复的只留第一次出现的位置;曲目表里已有的歌用服务端的元数据,没有的用文件里的,标题为空的跳过;新歌单受“最多 50 个”的限制;返回实际导入的数量", + "description": "把导出的歌单文件里的歌导入成一个新歌单,歌单名不合法时用“导入的歌单”。一次最多 1000 首,重复的歌只留第一次出现的位置。曲目表里已有的歌用服务端的元数据,没有的用文件里的元数据,其中标题为空的跳过。新歌单受“最多 50 个”的限制。返回实际导入的数量", "method": "POST", "path": "/api/me/playlists/imports", "auth": "member", @@ -1106,7 +1106,7 @@ }, { "name": "resolveMyPlaylistLink", - "description": "识别平台歌单的链接,短链接会跟随跳转;目前只支持网易云", + "description": "识别平台歌单的链接,短链接会跟随跳转。目前只支持网易云", "method": "POST", "path": "/api/me/playlist-links", "auth": "member", @@ -1130,7 +1130,7 @@ }, { "name": "importMyPlatformPlaylist", - "description": "把平台歌单导入成一个新歌单,规则同 importMyPlaylist,元数据取自平台;目前只支持网易云", + "description": "把平台歌单导入成一个新歌单,元数据取自平台。只收前 1000 首,重复的歌只留第一次出现的位置。歌单名不合法时返回 INVALID_ARGUMENT,不会像 importMyPlaylist 那样改用“导入的歌单”。新歌单受“最多 50 个”的限制。返回实际导入的数量。目前只支持网易云", "method": "POST", "path": "/api/me/playlists/platform-imports", "auth": "member", @@ -1207,7 +1207,7 @@ }, { "name": "createTicket", - "description": "换取进房票据;没有密码的房间可以不填 password", + "description": "换取进房票据。没有密码的房间可以不填 password", "method": "POST", "path": "/api/rooms/{id}/tickets", "auth": "member", @@ -1230,7 +1230,7 @@ }, { "name": "getSite", - "description": "品牌名、是否开放注册和是否提供 GitHub 登录;没有设置过品牌名时是 Alisten", + "description": "品牌名、是否开放注册和是否提供 GitHub 登录。没有设置过品牌名时,品牌名是 Alisten", "method": "GET", "path": "/api/site", "auth": "none", @@ -1240,7 +1240,7 @@ "properties": { "brandName": {"type": "string"}, "registrationOpen": {"type": "boolean"}, - "githubLogin": {"type": "boolean", "description": "后台配置了 GitHub OAuth App;为 false 时 GitHub 相关的接口返回 NOT_FOUND"} + "githubLogin": {"type": "boolean", "description": "后台是否配置了 GitHub OAuth App。为 false 时,GitHub 相关的接口返回 NOT_FOUND"} }, "required": ["brandName", "registrationOpen", "githubLogin"] } @@ -1290,7 +1290,7 @@ }, { "name": "adminUpdateRoom", - "description": "修改常驻房间;slug 或 password 为空字符串表示取消别名或密码", + "description": "修改常驻房间。slug 为空字符串表示取消别名,password 为空字符串表示取消密码", "method": "PATCH", "path": "/api/admin/rooms/{id}", "auth": "admin", @@ -1325,7 +1325,7 @@ "additionalProperties": false, "properties": { "set": {"type": "boolean", "description": "是否已经生成了 Key"}, - "createdAtMs": {"type": "integer", "description": "Key 的生成时间;还没有 Key 时为 0"} + "createdAtMs": {"type": "integer", "description": "Key 的生成时间,还没有 Key 时为 0"} }, "required": ["set", "createdAtMs"] } @@ -1486,7 +1486,7 @@ "additionalProperties": false, "properties": { "set": {"type": "boolean", "description": "是否保存了 cookies"}, - "updatedAtMs": {"type": "integer", "description": "最近一次填写的时间;未设置时为 0"} + "updatedAtMs": {"type": "integer", "description": "最近一次填写的时间,未设置时为 0"} }, "required": ["set", "updatedAtMs"] } @@ -1526,7 +1526,7 @@ "additionalProperties": false, "properties": { "clientId": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "没有配置时为 null"}, - "clientSecretSet": {"type": "boolean", "description": "是否保存了 Client Secret;它与 Client ID 一起保存、一起清除"}, + "clientSecretSet": {"type": "boolean", "description": "是否保存了 Client Secret。Client Secret 与 Client ID 一起保存、一起清除"}, "callbackUrl": {"type": "string", "description": "OAuth App 的 Authorization callback URL 要填这个地址"} }, "required": ["clientId", "clientSecretSet", "callbackUrl"] @@ -1534,7 +1534,7 @@ }, { "name": "adminSetGithubCredential", - "description": "保存 GitHub 登录使用的 OAuth App,开启 GitHub 登录;Client ID 和 Client Secret 一起提交,覆盖已有的配置,立即对所有实例生效。授权进行中换了 OAuth App 时,GitHub 不认原来签发的授权码,这次登录失败", + "description": "保存 GitHub 登录使用的 OAuth App,开启 GitHub 登录。Client ID 和 Client Secret 一起提交,覆盖已有的配置,立即对所有实例生效。授权进行中换了 OAuth App 时,GitHub 不认原来签发的授权码,这次登录失败", "method": "PUT", "path": "/api/admin/credentials/github", "auth": "admin", @@ -1551,7 +1551,7 @@ }, { "name": "adminDeleteGithubCredential", - "description": "清除 GitHub 登录使用的 OAuth App,关闭 GitHub 登录:GitHub 相关的接口返回 NOT_FOUND,已经绑定的 GitHub 保持绑定,已经登录的设备不受影响;只绑定了 GitHub 的账号没有密码,重新开启之前无法在其他设备上登录", + "description": "清除 GitHub 登录使用的 OAuth App,关闭 GitHub 登录。之后 GitHub 相关的接口返回 NOT_FOUND,已经绑定的 GitHub 保持绑定,已经登录的设备不受影响。只绑定了 GitHub 的账号没有密码,所以重新开启之前,新设备只能用已经登录的设备生成的配对码登录这个账号", "method": "DELETE", "path": "/api/admin/credentials/github", "auth": "admin", @@ -1559,7 +1559,7 @@ }, { "name": "adminListMedia", - "description": "已存储的链接曲目和 B 站的歌,每页 50 首;页码超出最后一页时 items 为空,其余字段照常", + "description": "已存储的链接曲目和 B 站的歌,每页 50 首。页码超出最后一页时 items 为空,其余字段照常返回", "method": "POST", "path": "/api/admin/media/queries", "auth": "admin", @@ -1569,7 +1569,7 @@ "properties": { "page": {"$ref": "#/$defs/Page", "description": "页码,从 1 开始,缺省为 1"}, "sort": {"$ref": "#/$defs/MediaSort", "description": "缺省为 stored"}, - "q": {"type": "string", "description": "按标题搜索:标题包含它就算匹配,不区分大小写,% 和 _ 按字面匹配;缺省或为空时不搜索", "maxLength": 100} + "q": {"type": "string", "description": "按标题搜索:标题包含 q 就算匹配,不区分大小写,% 和 _ 按字面匹配。缺省或为空时不搜索", "maxLength": 100} } }, "response": { @@ -1587,7 +1587,7 @@ }, { "name": "adminRemoveMedia", - "description": "删除若干首已存储的歌的音频和封面,并清除它们的准备结果,一次 1 到 50 首:正在房间里使用的不会删除,放进 inUse;不存在的忽略。逐首删除,中途出错时返回错误,出错之前已经删掉的不会恢复,用同样的请求重试即可", + "description": "删除若干首已存储的歌的音频和封面,并清除它们的准备结果,一次 1 到 50 首。正在房间里使用的歌不删除,放进 inUse。不存在的歌忽略。逐首删除,中途出错时返回错误,出错之前已经删掉的不会恢复,用同样的请求重试即可", "method": "POST", "path": "/api/admin/media/removals", "auth": "admin", @@ -1611,7 +1611,7 @@ }, { "name": "adminUpdateSite", - "description": "修改品牌名,返回存下的名字:去掉首尾空白后保存,应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符;设成 Alisten 就恢复了默认", + "description": "修改品牌名,返回存下的名字。品牌名去掉首尾空白后保存,应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符。设成 Alisten 就恢复了默认", "method": "PATCH", "path": "/api/admin/site", "auth": "admin", @@ -1663,7 +1663,7 @@ }, { "name": "adminResetAccountPassword", - "description": "重置账号的密码:生成随机的新密码,只在这次响应中返回;账号在所有设备上的成员 token 随即失效", + "description": "重置账号的密码,生成随机的新密码,新密码只在这次响应中返回。账号在所有设备上的成员 token 随即失效", "method": "POST", "path": "/api/admin/accounts/{username}/password", "auth": "admin", @@ -1683,7 +1683,7 @@ }, { "name": "listMyListens", - "description": "我的播放历史:在房间里听到过的歌,每首只留最近一次,最近听到的在前,最多 500 首;同一账号的所有设备看到同一份", + "description": "我的播放历史:在房间里听到过的歌,每首只留最近一次,最近听到的在前,最多 500 首。同一账号的所有设备看到的是同一份", "method": "GET", "path": "/api/me/listens", "auth": "member", @@ -1698,7 +1698,7 @@ }, { "name": "removeMyListen", - "description": "从播放历史中删除一首歌;不在历史里的忽略", + "description": "从播放历史中删除一首歌,不在历史里时忽略", "method": "POST", "path": "/api/me/listens/removals", "auth": "member", @@ -1743,7 +1743,7 @@ }, { "name": "shareMyPlaylist", - "description": "开启分享或重新生成分享链接:每次都生成新的 shareId,之前的链接随即失效;“我喜欢”也能分享", + "description": "开启分享或重新生成分享链接。每次都生成新的 shareId,之前的链接随即失效。“我喜欢”也能分享", "method": "POST", "path": "/api/me/playlists/{id}/share", "auth": "member", @@ -1759,7 +1759,7 @@ }, { "name": "unshareMyPlaylist", - "description": "停止分享:分享链接随即失效;没有分享时也算成功", + "description": "停止分享,分享链接随即失效。没有分享时也算成功", "method": "DELETE", "path": "/api/me/playlists/{id}/share", "auth": "member", @@ -1767,7 +1767,7 @@ }, { "name": "getSharedPlaylist", - "description": "读取分享的歌单,不需要身份;不返回所有者的成员 ID 和用户名;停止分享、重新生成链接或删除歌单之后返回 NOT_FOUND", + "description": "读取分享的歌单,不需要身份。不返回所有者的成员 ID 和用户名。停止分享、重新生成链接或删除歌单之后,用原来的 shareId 读取返回 NOT_FOUND", "method": "GET", "path": "/api/shared-playlists/{shareId}", "auth": "none", @@ -1775,7 +1775,7 @@ }, { "name": "copySharedPlaylist", - "description": "把分享的歌单复制成自己的新歌单,带上全部曲目和顺序;新歌单受“最多 50 个”的限制;分享已失效时返回 NOT_FOUND", + "description": "把分享的歌单复制成自己的新歌单,带上全部曲目和顺序。新歌单受“最多 50 个”的限制。分享已失效时返回 NOT_FOUND", "method": "POST", "path": "/api/shared-playlists/{shareId}/copies", "auth": "member", @@ -1792,7 +1792,7 @@ }, { "name": "createPairingCode", - "description": "已注册的账号生成配对码,新设备兑换之后登录这个账号:5 分钟内有效,只能兑换一次,发出请求的成员 token 失效时随之作废;游客调用时返回 FORBIDDEN", + "description": "已注册的账号生成配对码,新设备兑换之后就登录了这个账号。配对码 5 分钟内有效,只能兑换一次,发出请求的成员 token 失效时随之作废。游客调用时返回 FORBIDDEN", "method": "POST", "path": "/api/account/pairing-codes", "auth": "member", @@ -1815,7 +1815,7 @@ }, { "name": "redeemPairingCode", - "description": "游客兑换配对码,登录生成它的账号:返回账号的新成员 token,发出请求的游客 token 随即失效;配对码不对或已过期时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT", + "description": "游客兑换配对码,登录生成配对码的账号。返回账号的新成员 token,发出请求的游客 token 随即失效。配对码不对或已过期时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT", "method": "POST", "path": "/api/sessions/pairing", "auth": "member", @@ -1841,7 +1841,7 @@ }, { "name": "startGithubLogin", - "description": "发起 GitHub 授权,返回 GitHub 的授权地址。nonce 由客户端随机生成并保存,兑换时原样带上。GitHub 授权之后跳转到 /auth/github#code=<兑换码>,失败时跳转到 /auth/github#error=;client 为 app 时跳转到 moe.dihe.alisten:/auth/github,片段相同。只按发起授权时的 client 决定跳转目标,state 已经过期或用过时跳转到网页的 /auth/github;回调时后台已经关闭了 GitHub 登录,跳转到 failed。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND。游客发起 bind 或 verify 时返回 FORBIDDEN,已注册的账号发起 login、已经绑定了 GitHub 的账号发起 bind、设置了密码的账号发起 verify 时返回 CONFLICT。bind 要带上当前密码,没带或不对时返回 INVALID_CREDENTIALS:只凭成员 token 不能更换登录凭证,否则拿到 token 的人换上自己的 GitHub 之后,本人修改密码、后台重置密码都赶不走他", + "description": "发起 GitHub 授权,返回 GitHub 的授权地址。nonce 由客户端随机生成并保存,兑换时原样带上。GitHub 授权之后跳转到 /auth/github#code=<兑换码>,失败时跳转到 /auth/github#error=。client 为 app 时跳转到 moe.dihe.alisten:/auth/github,片段相同。跳转目标只按发起授权时的 client 决定,state 已经过期或用过时跳转到网页的 /auth/github。如果回调时后台已经关闭了 GitHub 登录,error 为 failed。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND。游客发起 bind 或 verify 时返回 FORBIDDEN。已注册的账号发起 login、已经绑定了 GitHub 的账号发起 bind、设置了密码的账号发起 verify 时返回 CONFLICT。bind 要带上当前密码,没带或不对时返回 INVALID_CREDENTIALS。要求密码是因为只凭成员 token 不能更换登录凭证,否则拿到 token 的人换上自己的 GitHub 之后,本人修改密码、后台重置密码都赶不走他", "method": "POST", "path": "/api/auth/github", "auth": "member", @@ -1851,7 +1851,7 @@ "additionalProperties": false, "properties": { "intent": {"$ref": "#/$defs/GitHubIntent"}, - "client": {"$ref": "#/$defs/GitHubClient", "description": "App 发起授权时填 app,让授权结束之后跳回 App;省略时为 web"}, + "client": {"$ref": "#/$defs/GitHubClient", "description": "App 发起授权时填 app,让授权结束之后跳回 App。省略时为 web"}, "nonce": {"type": "string", "minLength": 16, "maxLength": 128}, "currentPassword": {"$ref": "#/$defs/AccountPassword", "description": "当前密码,bind 时必须带上,其他意图忽略"} }, @@ -1868,7 +1868,7 @@ }, { "name": "finishGithubLogin", - "description": "用兑换码和发起授权时的 nonce 完成 GitHub 登录、注册或绑定,必须由发起授权的成员调用。GitHub 账号已经绑定了账号时登录它:返回账号的新成员 token,发出请求的游客 token 随即失效;没有绑定过时把它绑到发出请求的成员上(游客即注册),成员和成员 token 都不变。verify 不修改账号,GitHub 账号要是这个账号绑定的,否则返回 INVALID_CREDENTIALS;确认记在发出请求的成员 token 上,10 分钟内用它补设用户名和密码。兑换码过期、用过或 nonce 不对时返回 INVALID_CREDENTIALS,GitHub 账号已经绑定了其他账号时返回 CONFLICT,注册关闭时首次登录返回 FORBIDDEN,后台没有配置 GitHub OAuth App 时返回 NOT_FOUND", + "description": "用兑换码和发起授权时的 nonce 完成 GitHub 登录、注册或绑定,必须由发起授权的成员调用。GitHub 账号已经绑定了账号时,登录那个账号,返回账号的新成员 token,发出请求的游客 token 随即失效。GitHub 账号没有绑定过时,把这个 GitHub 账号绑到发出请求的成员上(游客即注册),成员和成员 token 都不变。verify 不修改账号,GitHub 账号要是这个账号绑定的那个,否则返回 INVALID_CREDENTIALS。verify 的确认记在发出请求的成员 token 上,要在 10 分钟内用这个 token 补设用户名和密码。兑换码过期、用过或 nonce 不对时返回 INVALID_CREDENTIALS,GitHub 账号已经绑定了其他账号时返回 CONFLICT,注册关闭时首次登录返回 FORBIDDEN,后台没有配置 GitHub OAuth App 时返回 NOT_FOUND", "method": "POST", "path": "/api/auth/github/exchange", "auth": "member", @@ -1894,7 +1894,7 @@ }, { "name": "unbindGithub", - "description": "解绑 GitHub;账号还没有设置密码时返回 CONFLICT,解绑之后它就再也无法登录。要带上当前密码,不对时返回 INVALID_CREDENTIALS:只凭成员 token 不能更换登录凭证,理由同 startGithubLogin 的 bind。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND", + "description": "解绑 GitHub。账号还没有设置密码时返回 CONFLICT,因为解绑之后这个账号就再也无法登录。要带上当前密码,不对时返回 INVALID_CREDENTIALS。只凭成员 token 不能更换登录凭证,理由同 startGithubLogin 的 bind。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND", "method": "POST", "path": "/api/account/github/removals", "auth": "member", diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index 12306d4..8118553 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -5,7 +5,7 @@ "additionalProperties": false, "properties": { "key": { - "description": "常驻房间的 API Key,只在生成或重置时返回这一次:bot 用 Authorization: Bearer 调用 /api/room 下的接口", + "description": "常驻房间的 API Key,只在生成或重置时返回这一次。bot 用 Authorization: Bearer 调用 /api/room 下的接口", "type": "string" } }, @@ -212,7 +212,7 @@ "description": "没有配置时为 null" }, "clientSecretSet": { - "description": "是否保存了 Client Secret;它与 Client ID 一起保存、一起清除", + "description": "是否保存了 Client Secret。Client Secret 与 Client ID 一起保存、一起清除", "type": "boolean" } }, @@ -275,7 +275,7 @@ "additionalProperties": false, "properties": { "createdAtMs": { - "description": "Key 的生成时间;还没有 Key 时为 0", + "description": "Key 的生成时间,还没有 Key 时为 0", "type": "integer" }, "set": { @@ -297,7 +297,7 @@ "type": "boolean" }, "updatedAtMs": { - "description": "最近一次填写的时间;未设置时为 0", + "description": "最近一次填写的时间,未设置时为 0", "type": "integer" } }, @@ -316,7 +316,7 @@ "type": "integer" }, "q": { - "description": "按标题搜索:标题包含它就算匹配,不区分大小写,% 和 _ 按字面匹配;缺省或为空时不搜索", + "description": "按标题搜索:标题包含 q 就算匹配,不区分大小写,% 和 _ 按字面匹配。缺省或为空时不搜索", "maxLength": 100, "type": "string" }, @@ -928,7 +928,7 @@ "type": "string" }, "githubLogin": { - "description": "后台配置了 GitHub OAuth App;为 false 时 GitHub 相关的接口返回 NOT_FOUND", + "description": "后台是否配置了 GitHub OAuth App。为 false 时,GitHub 相关的接口返回 NOT_FOUND", "type": "boolean" }, "registrationOpen": { @@ -943,7 +943,7 @@ "type": "object" }, "GitHubClient": { - "description": "发起 GitHub 授权的客户端,决定授权结束之后跳到哪里。web:网页的 /auth/github;app:App 用系统浏览器授权,授权结束之后跳到 moe.dihe.alisten:/auth/github,回到 App", + "description": "发起 GitHub 授权的客户端,决定授权结束之后跳到哪里。web:跳到网页的 /auth/github。app:App 用系统浏览器授权,授权结束之后跳到 moe.dihe.alisten:/auth/github,回到 App", "enum": [ "web", "app" @@ -951,7 +951,7 @@ "type": "string" }, "GitHubIntent": { - "description": "login:游客用 GitHub 登录,GitHub 账号没有绑定过时就是注册;bind:已注册的账号绑定 GitHub;verify:只绑定了 GitHub 的账号补设用户名和密码之前,用绑定的 GitHub 确认身份", + "description": "login:游客用 GitHub 登录,GitHub 账号没有绑定过时就是注册。bind:已注册的账号绑定 GitHub。verify:只绑定了 GitHub 的账号补设用户名和密码之前,用绑定的 GitHub 确认身份", "enum": [ "login", "bind", @@ -1166,7 +1166,7 @@ "additionalProperties": false, "properties": { "device": { - "description": "本机此前登录这个账号时拿到的凭证:有人反复猜这个账号的密码时,带上它本机仍然能登录", + "description": "本机此前登录这个账号时拿到的凭证。有人反复猜这个账号的密码时,本机带上这个凭证仍然能登录", "pattern": "^[A-Za-z0-9_-]{43}$", "type": "string" }, @@ -1195,7 +1195,7 @@ "$ref": "#/components/schemas/Account" }, "device": { - "description": "本机以后登录这个账号时要带上的凭证,退出登录之后也要保留:带来的凭证有效时原样返回,否则是新签发的", + "description": "本机以后登录这个账号时要带上的凭证,退出登录之后也要保留。带来的凭证有效时原样返回,否则返回新签发的凭证", "pattern": "^[A-Za-z0-9_-]{43}$", "type": "string" }, @@ -1215,7 +1215,7 @@ "type": "object" }, "MediaSort": { - "description": "stored:存储时间从新到旧;idle:最近播放从早到晚,从没播放过的排最前;size:占用从大到小。三种排序都以曲目 ID 作为最后的排序键,翻页时不重复也不遗漏", + "description": "stored:存储时间从新到旧。idle:最近播放从早到晚,从没播放过的排最前。size:占用从大到小。三种排序都以曲目 ID 作为最后的排序键,所以翻页时不重复也不遗漏", "enum": [ "stored", "idle", @@ -1318,7 +1318,7 @@ "type": "null" } ], - "description": "歌单里排在最前、有封面的那首歌的封面;没有时为 null" + "description": "歌单里排在最前、有封面的那首歌的封面。歌单里没有带封面的歌时为 null" }, "createdAtMs": { "type": "integer" @@ -1342,7 +1342,7 @@ "type": "null" } ], - "description": "分享链接 /s/ 中的 ID;没有分享时为 null" + "description": "分享链接 /s/ 中的 ID,没有分享时为 null" }, "trackCount": { "type": "integer" @@ -1527,7 +1527,7 @@ "type": "integer" }, "key": { - "description": "查询扫码结果时原样带上,只放在请求体中:有效期内它相当于这次登录的凭证", + "description": "查询扫码结果时原样带上。有效期内 key 相当于这次登录的凭证,所以只放在请求体中", "type": "string" }, "qrImage": { @@ -2016,7 +2016,7 @@ "type": "boolean" }, "items": { - "description": "新加入的在前", + "description": "顺序与所有者在 listMyPlaylistItems 里看到的相同", "items": { "$ref": "#/components/schemas/MyPlaylistItem" }, @@ -2071,7 +2071,7 @@ "description": "账号昵称,未知时为 null" }, "verified": { - "description": "是否由上游实时查询确认;仅从本地凭据识别时为 false", + "description": "是否由上游实时查询确认,仅从本地凭据识别时为 false", "type": "boolean" }, "vip": { @@ -2099,7 +2099,7 @@ "properties": { "client": { "$ref": "#/components/schemas/GitHubClient", - "description": "App 发起授权时填 app,让授权结束之后跳回 App;省略时为 web" + "description": "App 发起授权时填 app,让授权结束之后跳回 App。省略时为 web" }, "currentPassword": { "description": "当前密码,bind 时必须带上,其他意图忽略", @@ -2146,7 +2146,7 @@ "type": "string" }, "key": { - "description": "曲目 ID 的 SHA-256(十六进制),用于删除;链接曲目的 ID 是规范化之后的链接", + "description": "曲目 ID 的 SHA-256(十六进制),用于删除。链接曲目的 ID 是规范化之后的链接", "type": "string" }, "lastPlayedAtMs": { @@ -2253,7 +2253,7 @@ ], "properties": { "id": { - "description": "来源为 link 时是链接,响应和事件中是规范化之后的链接(scheme 和主机名转成小写);来源为 bilibili 时是 BV 号,或从第 2 P 起的 BV 号/分P序号", + "description": "来源为 link 时是链接,响应和事件中是规范化之后的链接(scheme 和主机名转成小写)。来源为 bilibili 时,第 1 P 是 BV 号,从第 2 P 起是 BV 号/分P序号", "type": "string" }, "source": { @@ -2378,7 +2378,7 @@ }, "info": { "title": "Alisten", - "version": "ce462456c63356c6" + "version": "d68525cd68ef691f" }, "openapi": "3.1.0", "paths": { @@ -2422,7 +2422,7 @@ "memberToken": [] } ], - "summary": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制,但发出请求的成员 token 要在 10 分钟内用绑定的 GitHub 确认过身份(startGithubLogin 的 verify),没有确认时返回 FORBIDDEN:拿到成员 token 的人不能抢先设成自己的密码。补设成功之后这次确认作废,账号在其他设备上的成员 token 随即失效,与修改密码相同;失败时确认仍然有效" + "summary": "游客注册账号,设置用户名和密码,成员和成员 token 都不变。站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用 register 补设用户名和密码,不受注册开关限制。但发出请求的成员 token 要在 10 分钟内用绑定的 GitHub 确认过身份(startGithubLogin 的 verify),没有确认时返回 FORBIDDEN。这样拿到成员 token 的人就不能抢先设成自己的密码。补设成功之后这次确认作废,账号在其他设备上的成员 token 随即失效,这与修改密码相同。补设失败时确认仍然有效" } }, "/api/account/github/removals": { @@ -2458,7 +2458,7 @@ "memberToken": [] } ], - "summary": "解绑 GitHub;账号还没有设置密码时返回 CONFLICT,解绑之后它就再也无法登录。要带上当前密码,不对时返回 INVALID_CREDENTIALS:只凭成员 token 不能更换登录凭证,理由同 startGithubLogin 的 bind。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND" + "summary": "解绑 GitHub。账号还没有设置密码时返回 CONFLICT,因为解绑之后这个账号就再也无法登录。要带上当前密码,不对时返回 INVALID_CREDENTIALS。只凭成员 token 不能更换登录凭证,理由同 startGithubLogin 的 bind。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND" } }, "/api/account/pairing-codes": { @@ -2501,7 +2501,7 @@ "memberToken": [] } ], - "summary": "已注册的账号生成配对码,新设备兑换之后登录这个账号:5 分钟内有效,只能兑换一次,发出请求的成员 token 失效时随之作废;游客调用时返回 FORBIDDEN" + "summary": "已注册的账号生成配对码,新设备兑换之后就登录了这个账号。配对码 5 分钟内有效,只能兑换一次,发出请求的成员 token 失效时随之作废。游客调用时返回 FORBIDDEN" } }, "/api/account/password": { @@ -2537,7 +2537,7 @@ "memberToken": [] } ], - "summary": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS,账号还没有设置密码时返回 CONFLICT" + "summary": "修改密码。账号在其他设备上的成员 token 随即失效,发出请求的成员 token 不变。当前密码错误时返回 INVALID_CREDENTIALS,账号还没有设置密码时返回 CONFLICT" } }, "/api/admin/accounts/{username}": { @@ -2633,7 +2633,7 @@ "adminToken": [] } ], - "summary": "重置账号的密码:生成随机的新密码,只在这次响应中返回;账号在所有设备上的成员 token 随即失效" + "summary": "重置账号的密码,生成随机的新密码,新密码只在这次响应中返回。账号在所有设备上的成员 token 随即失效" } }, "/api/admin/credentials/bilibili": { @@ -2778,7 +2778,7 @@ "adminToken": [] } ], - "summary": "清除 GitHub 登录使用的 OAuth App,关闭 GitHub 登录:GitHub 相关的接口返回 NOT_FOUND,已经绑定的 GitHub 保持绑定,已经登录的设备不受影响;只绑定了 GitHub 的账号没有密码,重新开启之前无法在其他设备上登录" + "summary": "清除 GitHub 登录使用的 OAuth App,关闭 GitHub 登录。之后 GitHub 相关的接口返回 NOT_FOUND,已经绑定的 GitHub 保持绑定,已经登录的设备不受影响。只绑定了 GitHub 的账号没有密码,所以重新开启之前,新设备只能用已经登录的设备生成的配对码登录这个账号" }, "get": { "operationId": "adminGetGithubCredential", @@ -2843,7 +2843,7 @@ "adminToken": [] } ], - "summary": "保存 GitHub 登录使用的 OAuth App,开启 GitHub 登录;Client ID 和 Client Secret 一起提交,覆盖已有的配置,立即对所有实例生效。授权进行中换了 OAuth App 时,GitHub 不认原来签发的授权码,这次登录失败" + "summary": "保存 GitHub 登录使用的 OAuth App,开启 GitHub 登录。Client ID 和 Client Secret 一起提交,覆盖已有的配置,立即对所有实例生效。授权进行中换了 OAuth App 时,GitHub 不认原来签发的授权码,这次登录失败" } }, "/api/admin/credentials/netease": { @@ -3215,7 +3215,7 @@ "adminToken": [] } ], - "summary": "已存储的链接曲目和 B 站的歌,每页 50 首;页码超出最后一页时 items 为空,其余字段照常" + "summary": "已存储的链接曲目和 B 站的歌,每页 50 首。页码超出最后一页时 items 为空,其余字段照常返回" } }, "/api/admin/media/removals": { @@ -3258,7 +3258,7 @@ "adminToken": [] } ], - "summary": "删除若干首已存储的歌的音频和封面,并清除它们的准备结果,一次 1 到 50 首:正在房间里使用的不会删除,放进 inUse;不存在的忽略。逐首删除,中途出错时返回错误,出错之前已经删掉的不会恢复,用同样的请求重试即可" + "summary": "删除若干首已存储的歌的音频和封面,并清除它们的准备结果,一次 1 到 50 首。正在房间里使用的歌不删除,放进 inUse。不存在的歌忽略。逐首删除,中途出错时返回错误,出错之前已经删掉的不会恢复,用同样的请求重试即可" } }, "/api/admin/registration": { @@ -3480,7 +3480,7 @@ "adminToken": [] } ], - "summary": "修改常驻房间;slug 或 password 为空字符串表示取消别名或密码" + "summary": "修改常驻房间。slug 为空字符串表示取消别名,password 为空字符串表示取消密码" } }, "/api/admin/rooms/{id}/api-key": { @@ -3625,7 +3625,7 @@ "adminToken": [] } ], - "summary": "修改品牌名,返回存下的名字:去掉首尾空白后保存,应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符;设成 Alisten 就恢复了默认" + "summary": "修改品牌名,返回存下的名字。品牌名去掉首尾空白后保存,应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符。设成 Alisten 就恢复了默认" } }, "/api/auth/github": { @@ -3668,7 +3668,7 @@ "memberToken": [] } ], - "summary": "发起 GitHub 授权,返回 GitHub 的授权地址。nonce 由客户端随机生成并保存,兑换时原样带上。GitHub 授权之后跳转到 /auth/github#code=<兑换码>,失败时跳转到 /auth/github#error=;client 为 app 时跳转到 moe.dihe.alisten:/auth/github,片段相同。只按发起授权时的 client 决定跳转目标,state 已经过期或用过时跳转到网页的 /auth/github;回调时后台已经关闭了 GitHub 登录,跳转到 failed。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND。游客发起 bind 或 verify 时返回 FORBIDDEN,已注册的账号发起 login、已经绑定了 GitHub 的账号发起 bind、设置了密码的账号发起 verify 时返回 CONFLICT。bind 要带上当前密码,没带或不对时返回 INVALID_CREDENTIALS:只凭成员 token 不能更换登录凭证,否则拿到 token 的人换上自己的 GitHub 之后,本人修改密码、后台重置密码都赶不走他" + "summary": "发起 GitHub 授权,返回 GitHub 的授权地址。nonce 由客户端随机生成并保存,兑换时原样带上。GitHub 授权之后跳转到 /auth/github#code=<兑换码>,失败时跳转到 /auth/github#error=。client 为 app 时跳转到 moe.dihe.alisten:/auth/github,片段相同。跳转目标只按发起授权时的 client 决定,state 已经过期或用过时跳转到网页的 /auth/github。如果回调时后台已经关闭了 GitHub 登录,error 为 failed。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND。游客发起 bind 或 verify 时返回 FORBIDDEN。已注册的账号发起 login、已经绑定了 GitHub 的账号发起 bind、设置了密码的账号发起 verify 时返回 CONFLICT。bind 要带上当前密码,没带或不对时返回 INVALID_CREDENTIALS。要求密码是因为只凭成员 token 不能更换登录凭证,否则拿到 token 的人换上自己的 GitHub 之后,本人修改密码、后台重置密码都赶不走他" } }, "/api/auth/github/exchange": { @@ -3711,7 +3711,7 @@ "memberToken": [] } ], - "summary": "用兑换码和发起授权时的 nonce 完成 GitHub 登录、注册或绑定,必须由发起授权的成员调用。GitHub 账号已经绑定了账号时登录它:返回账号的新成员 token,发出请求的游客 token 随即失效;没有绑定过时把它绑到发出请求的成员上(游客即注册),成员和成员 token 都不变。verify 不修改账号,GitHub 账号要是这个账号绑定的,否则返回 INVALID_CREDENTIALS;确认记在发出请求的成员 token 上,10 分钟内用它补设用户名和密码。兑换码过期、用过或 nonce 不对时返回 INVALID_CREDENTIALS,GitHub 账号已经绑定了其他账号时返回 CONFLICT,注册关闭时首次登录返回 FORBIDDEN,后台没有配置 GitHub OAuth App 时返回 NOT_FOUND" + "summary": "用兑换码和发起授权时的 nonce 完成 GitHub 登录、注册或绑定,必须由发起授权的成员调用。GitHub 账号已经绑定了账号时,登录那个账号,返回账号的新成员 token,发出请求的游客 token 随即失效。GitHub 账号没有绑定过时,把这个 GitHub 账号绑到发出请求的成员上(游客即注册),成员和成员 token 都不变。verify 不修改账号,GitHub 账号要是这个账号绑定的那个,否则返回 INVALID_CREDENTIALS。verify 的确认记在发出请求的成员 token 上,要在 10 分钟内用这个 token 补设用户名和密码。兑换码过期、用过或 nonce 不对时返回 INVALID_CREDENTIALS,GitHub 账号已经绑定了其他账号时返回 CONFLICT,注册关闭时首次登录返回 FORBIDDEN,后台没有配置 GitHub OAuth App 时返回 NOT_FOUND" } }, "/api/me": { @@ -3744,7 +3744,7 @@ "memberToken": [] } ], - "summary": "当前成员的资料;已注册的成员带上账号" + "summary": "当前成员的资料。已注册的成员还带上账号" }, "patch": { "operationId": "updateMe", @@ -3785,7 +3785,7 @@ "memberToken": [] } ], - "summary": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除" + "summary": "修改昵称和头像。email 用来计算头像哈希,已注册的账号还会同时保存 email。email 去掉首尾空白后应形如 name@example.com,空字符串表示清除" } }, "/api/me/listen-recording": { @@ -3902,7 +3902,7 @@ "memberToken": [] } ], - "summary": "我的播放历史:在房间里听到过的歌,每首只留最近一次,最近听到的在前,最多 500 首;同一账号的所有设备看到同一份" + "summary": "我的播放历史:在房间里听到过的歌,每首只留最近一次,最近听到的在前,最多 500 首。同一账号的所有设备看到的是同一份" } }, "/api/me/listens/removals": { @@ -3938,7 +3938,7 @@ "memberToken": [] } ], - "summary": "从播放历史中删除一首歌;不在历史里的忽略" + "summary": "从播放历史中删除一首歌,不在历史里时忽略" } }, "/api/me/playlist-links": { @@ -3981,7 +3981,7 @@ "memberToken": [] } ], - "summary": "识别平台歌单的链接,短链接会跟随跳转;目前只支持网易云" + "summary": "识别平台歌单的链接,短链接会跟随跳转。目前只支持网易云" } }, "/api/me/playlist-memberships": { @@ -4057,7 +4057,7 @@ "memberToken": [] } ], - "summary": "我的歌单:“我喜欢”在最前,其余按创建的先后;只有已注册的账号能使用歌单" + "summary": "我的歌单:“我喜欢”在最前,其余按创建的先后排列。只有已注册的账号能使用歌单" }, "post": { "operationId": "createMyPlaylist", @@ -4141,7 +4141,7 @@ "memberToken": [] } ], - "summary": "把导出的歌单文件里的歌导入成一个新歌单,歌单名不合法时用“导入的歌单”;只收前 1000 首,重复的只留第一次出现的位置;曲目表里已有的歌用服务端的元数据,没有的用文件里的,标题为空的跳过;新歌单受“最多 50 个”的限制;返回实际导入的数量" + "summary": "把导出的歌单文件里的歌导入成一个新歌单,歌单名不合法时用“导入的歌单”。一次最多 1000 首,重复的歌只留第一次出现的位置。曲目表里已有的歌用服务端的元数据,没有的用文件里的元数据,其中标题为空的跳过。新歌单受“最多 50 个”的限制。返回实际导入的数量" } }, "/api/me/playlists/platform-imports": { @@ -4184,7 +4184,7 @@ "memberToken": [] } ], - "summary": "把平台歌单导入成一个新歌单,规则同 importMyPlaylist,元数据取自平台;目前只支持网易云" + "summary": "把平台歌单导入成一个新歌单,元数据取自平台。只收前 1000 首,重复的歌只留第一次出现的位置。歌单名不合法时返回 INVALID_ARGUMENT,不会像 importMyPlaylist 那样改用“导入的歌单”。新歌单受“最多 50 个”的限制。返回实际导入的数量。目前只支持网易云" } }, "/api/me/playlists/{id}": { @@ -4220,7 +4220,7 @@ "memberToken": [] } ], - "summary": "删除歌单;“我喜欢”不能删除" + "summary": "删除歌单。“我喜欢”不能删除" }, "patch": { "operationId": "renameMyPlaylist", @@ -4264,7 +4264,7 @@ "memberToken": [] } ], - "summary": "重命名歌单;“我喜欢”不能重命名" + "summary": "重命名歌单。“我喜欢”不能重命名" } }, "/api/me/playlists/{id}/copies": { @@ -4317,7 +4317,7 @@ "memberToken": [] } ], - "summary": "把另一个歌单里的若干首歌连同元数据复制到这个歌单,一次 1 到 1000 首;不在来源歌单里的和已在这个歌单里的跳过;新复制的排在最前,保持 tracks 的先后;装不下时整批拒绝;返回实际复制的数量" + "summary": "把另一个歌单里的若干首歌连同元数据复制到这个歌单,一次 1 到 1000 首。不在来源歌单里的歌和已经在这个歌单里的歌跳过。新复制的歌排在最前,彼此之间保持 tracks 里的先后。装不下时整批拒绝。返回实际复制的数量" } }, "/api/me/playlists/{id}/items": { @@ -4360,7 +4360,7 @@ "memberToken": [] } ], - "summary": "歌单里的歌,新加入的在前" + "summary": "歌单里的歌,按歌单里的顺序排列。新加入的歌排在最前,moveMyPlaylistItems 可以调整顺序" }, "post": { "operationId": "addMyPlaylistItem", @@ -4411,7 +4411,7 @@ "memberToken": [] } ], - "summary": "把一首歌加入歌单:元数据由服务端查询,链接曲目要先准备好;已经在歌单里时不变;一个歌单最多 1000 首" + "summary": "把一首歌加入歌单,元数据由服务端查询。链接曲目要先准备好。歌已经在歌单里时,歌单不变。一个歌单最多 1000 首" } }, "/api/me/playlists/{id}/moves": { @@ -4457,7 +4457,7 @@ "memberToken": [] } ], - "summary": "调整歌单里的顺序:把 tracks 里在歌单中的歌按给出的先后放到 before 之前,不给 before 就放到最后;一次 1 到 1000 首;before 不能在 tracks 里,也必须还在歌单里" + "summary": "调整歌单里的顺序:把 tracks 里在歌单中的歌按给出的先后放到 before 之前,不给 before 就放到最后。一次 1 到 1000 首。before 不能在 tracks 里,也必须还在歌单里" } }, "/api/me/playlists/{id}/removals": { @@ -4503,7 +4503,7 @@ "memberToken": [] } ], - "summary": "把若干首歌移出歌单,一次 1 到 1000 首;不在歌单里的忽略" + "summary": "把若干首歌移出歌单,一次 1 到 1000 首。不在歌单里的歌忽略" } }, "/api/me/playlists/{id}/share": { @@ -4539,7 +4539,7 @@ "memberToken": [] } ], - "summary": "停止分享:分享链接随即失效;没有分享时也算成功" + "summary": "停止分享,分享链接随即失效。没有分享时也算成功" }, "post": { "operationId": "shareMyPlaylist", @@ -4590,7 +4590,7 @@ "memberToken": [] } ], - "summary": "开启分享或重新生成分享链接:每次都生成新的 shareId,之前的链接随即失效;“我喜欢”也能分享" + "summary": "开启分享或重新生成分享链接。每次都生成新的 shareId,之前的链接随即失效。“我喜欢”也能分享" } }, "/api/members": { @@ -5279,7 +5279,7 @@ "memberToken": [] } ], - "summary": "换取进房票据;没有密码的房间可以不填 password" + "summary": "换取进房票据。没有密码的房间可以不填 password" } }, "/api/rooms/{ref}": { @@ -5361,7 +5361,7 @@ "memberToken": [] } ], - "summary": "游客登录账号:返回账号的新成员 token,发出请求的游客 token 随即失效;用户名或密码错误时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT" + "summary": "游客登录账号,返回账号的新成员 token,发出请求的游客 token 随即失效。用户名或密码错误时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT" } }, "/api/sessions/current": { @@ -5430,7 +5430,7 @@ "memberToken": [] } ], - "summary": "游客兑换配对码,登录生成它的账号:返回账号的新成员 token,发出请求的游客 token 随即失效;配对码不对或已过期时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT" + "summary": "游客兑换配对码,登录生成配对码的账号。返回账号的新成员 token,发出请求的游客 token 随即失效。配对码不对或已过期时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT" } }, "/api/shared-playlists/{shareId}": { @@ -5469,7 +5469,7 @@ } }, "security": [], - "summary": "读取分享的歌单,不需要身份;不返回所有者的成员 ID 和用户名;停止分享、重新生成链接或删除歌单之后返回 NOT_FOUND" + "summary": "读取分享的歌单,不需要身份。不返回所有者的成员 ID 和用户名。停止分享、重新生成链接或删除歌单之后,用原来的 shareId 读取返回 NOT_FOUND" } }, "/api/shared-playlists/{shareId}/copies": { @@ -5522,7 +5522,7 @@ "memberToken": [] } ], - "summary": "把分享的歌单复制成自己的新歌单,带上全部曲目和顺序;新歌单受“最多 50 个”的限制;分享已失效时返回 NOT_FOUND" + "summary": "把分享的歌单复制成自己的新歌单,带上全部曲目和顺序。新歌单受“最多 50 个”的限制。分享已失效时返回 NOT_FOUND" } }, "/api/site": { @@ -5551,7 +5551,7 @@ } }, "security": [], - "summary": "品牌名、是否开放注册和是否提供 GitHub 登录;没有设置过品牌名时是 Alisten" + "summary": "品牌名、是否开放注册和是否提供 GitHub 登录。没有设置过品牌名时,品牌名是 Alisten" } } } diff --git a/protocol/types.gen.go b/protocol/types.gen.go index 1b1766a..4362f8c 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -4,7 +4,7 @@ package protocol import "encoding/json" -const Version = "ce462456c63356c6" +const Version = "d68525cd68ef691f" type APIKey struct { Key string `json:"key"` diff --git a/protocol/ws.go b/protocol/ws.go index 14f18e1..11c8c11 100644 --- a/protocol/ws.go +++ b/protocol/ws.go @@ -10,7 +10,7 @@ type Message struct { Kind string `json:"kind"` Type string `json:"type"` ID string `json:"id,omitempty"` - // 房间事件的 term 和 seq 都从 1 开始;响应和 error 事件不带编号,取 0 时不编码。 + // 房间事件的 term 和 seq 都从 1 开始。响应和 error 事件不带编号,取 0 时不编码。 Term int64 `json:"term,omitempty"` Seq int64 `json:"seq,omitempty"` Data any `json:"data"` @@ -39,7 +39,7 @@ type Request struct { Data any } -// DecodeRequest 返回错误时,ID 为空表示这一帧无法对应到请求,应发送 error 事件;否则回复这个请求的失败响应。 +// DecodeRequest 返回错误时,ID 为空表示这一帧无法对应到请求,应发送 error 事件。ID 不为空时,回复这个请求的失败响应。 func DecodeRequest(frame []byte) (Request, error) { var env map[string]json.RawMessage if json.Unmarshal(frame, &env) != nil || env == nil { diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index e95d746..36e176b 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -1,6 +1,6 @@ import antfu from '@antfu/eslint-config' -// 一个文件匹配到多个设置 no-restricted-imports 的配置对象时,只有最后一个生效:features 因此要带上这一条;core 和 shared 禁止引用 features 的任何路径,已经包含它。 +// 一个文件匹配到多个设置了 no-restricted-imports 的配置对象时,只有最后一个生效。所以 features 的配置要再带上 featureInternals。core 和 shared 的配置禁止引用 features 下的任何路径,已经包含了 featureInternals。 const featureInternals = { pattern: /^(?:@\/|\.\.?\/(?:.*\/)?)features\/[^/]+\/[^/]/i, message: '功能之间只能通过对方的 index.ts 引用。', @@ -12,7 +12,7 @@ const appImports = { } // no-restricted-imports 不检查 import(),动态导入由 no-restricted-syntax 用同一个正则检查。 -// no-restricted-syntax 同样只有最后一个配置对象生效,所以要带上 @antfu/eslint-config 设置的两项,升级 antfu 时要与它保持一致。 +// no-restricted-syntax 同样只有最后一个配置对象生效,所以要带上 @antfu/eslint-config 设置的两项。升级 antfu 时,这两项要和它保持一致。 function restrictImports(...restrictions) { return { 'no-restricted-imports': ['error', { patterns: restrictions.map(({ pattern, message }) => ({ regex: pattern.source, message })) }], diff --git a/web/nginx/templates/alisten.d/proxy.conf.template b/web/nginx/templates/alisten.d/proxy.conf.template index 73c3188..7837d61 100644 --- a/web/nginx/templates/alisten.d/proxy.conf.template +++ b/web/nginx/templates/alisten.d/proxy.conf.template @@ -1,18 +1,18 @@ -# 主机名放在变量中,nginx 才会在每个请求时解析:serve 有两个副本且重启后地址会变,启动时上游也不必已经存在。 +# serve 有两个副本,重启后地址会变,所以把主机名放在变量里,nginx 才会在每个请求时解析。这样启动时上游也不必已经存在。 resolver 127.0.0.11 valid=5s; set $serve serve:8080; set $rustfs rustfs:9000; proxy_http_version 1.1; absolute_redirect off; -# default.conf 中有匹配以 / 结尾的路径的正则 location,只有带 ^~ 的前缀 location 才能优先于它。 +# default.conf 里有一个正则 location 匹配以 / 结尾的路径。前缀 location 只有带上 ^~ 才能优先于这个正则 location。 location ^~ /api/ { - # 不小于 serve 的请求体上限,请求体就不会写入临时文件,也就不会因此输出纯文本的 warn 日志。 + # 缓冲区不小于 serve 的请求体上限,请求体就不会写进临时文件,nginx 也就不会因此输出纯文本的 warn 日志。 client_body_buffer_size 64k; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://$serve; - # 取值必须不小于 serve 对批量歌单接口的请求体上限 maxBatchBodyBytes(2304000 字节),否则大请求会被 nginx 以 HTML 的 413 拒绝,或写入临时文件。 + # 这两项取值不能小于 serve 给批量歌单接口定的请求体上限 maxBatchBodyBytes(2304000 字节)。否则大请求会被 nginx 用 HTML 的 413 拒绝,或者写进临时文件。 location ~ ^/api/me/playlists/[^/]+/(removals|copies|moves)$ { client_max_body_size 2250k; client_body_buffer_size 2250k; @@ -20,7 +20,7 @@ location ^~ /api/ { proxy_pass http://$serve; } - # 取值必须不小于 serve 对导入文件接口的请求体上限 maxImportBodyBytes(1 MiB),原因同上。 + # 这两项取值不能小于 serve 给导入文件接口定的请求体上限 maxImportBodyBytes(1 MiB),原因同上。 location = /api/me/playlists/imports { client_max_body_size 1m; client_body_buffer_size 1m; @@ -39,8 +39,8 @@ location = /ws { proxy_read_timeout 1h; proxy_send_timeout 1h; } -# 只转发 link/ 下的读取请求:RustFS 的其他接口也在同一端口上。对象来自用户提交的链接且与页面同源,依靠这两个响应头阻止其中的脚本运行。 -# 用 return 而不用 limit_except:拒绝时不写纯文本的错误日志。关闭缓冲:缓冲时超出内存缓冲区的响应会写入 /tmp 的 tmpfs。 +# 只转发 link/ 下的读取请求,因为 RustFS 的其他接口也在同一个端口上。这些对象来自用户提交的链接,又和页面同源,所以靠末尾的两个响应头阻止其中的脚本运行。 +# 用 return 而不用 limit_except,这样拒绝时不会写纯文本的错误日志。关闭缓冲,因为开着缓冲时,超出内存缓冲区的响应会写进 /tmp 的 tmpfs。 location ^~ /storage/${STORAGE_BUCKET_NAME}/link/ { if ($request_method !~ ^(GET|HEAD)$) { return 405; diff --git a/web/src/app/app.tsx b/web/src/app/app.tsx index db39c5c..4c04e6e 100644 --- a/web/src/app/app.tsx +++ b/web/src/app/app.tsx @@ -22,7 +22,7 @@ import { pwaStore } from './pwa' import { UpdatePrompt } from './update-prompt' import { panelDirection, withViewTransition } from './view-transition' -// adminStore 不在 main.tsx 中启动:静态导入会把后台页打包进入口 chunk。 +// adminStore 不在 main.tsx 里启动,因为静态导入会把后台页打包进入口 chunk。 const AdminPage = lazyPage(() => import('@/features/admin').then((module) => { module.adminStore.start() return module.AdminPage @@ -181,7 +181,7 @@ function Header({ page }: { page: Page }) { ) } -// 页面自己订阅地址里的参数:筛选框每按一次键都改写地址,应用壳不必跟着重新渲染。 +// 页面自己订阅地址里的参数,这样筛选框每按一次键改写地址时,应用壳不必跟着重新渲染。 function RoutedLobby({ onEntered }: { onEntered: (ref: string) => void }) { const route = useStore(navigationStore, s => s.route) if (route.name !== 'lobby') @@ -287,7 +287,7 @@ export function App() { const songStartedAtMs = useStore(roomStore, s => s.room?.nowPlaying?.startedAtMs) const browse = () => navigationStore.open('lobby') const showRoom = (ref: string) => navigationStore.navigate({ name: 'listen', ref, panel: null }) - // 加入房间前不显示面板:底部的切换按钮此时不可用,而聊天视图的关闭按钮在 RoomGate 内,打开的聊天面板会无法关闭。 + // 加入房间前不显示面板,因为这时底部的切换按钮不可用,聊天视图的关闭按钮又在 RoomGate 里面,聊天面板一旦打开就关不掉。 const shownPanel = joined && panel !== page ? panel : null const switchPanel = (next: Panel | null) => { if (shownPanel && next && next !== shownPanel && next !== page) @@ -309,7 +309,7 @@ export function App() { } return ( <> - {/* 播放引擎在房间为空时就已挂载,出错只可能源于运行环境,手动重试很可能同样失败,因此不提供重试按钮。 */} + {/* 不提供重试按钮,因为播放引擎在房间为空时就已经挂载,出错只可能源于运行环境,手动重试多半也会失败。 */} notificationStore.error('音乐无法播放。下一首歌开始时会自动再试,也可以刷新页面再试。', 8000)}> void voteSkip()} /> @@ -394,7 +394,7 @@ export function App() { fallback={null} resetOn={roomEntryStore} onError={() => { - // 弹窗已不再显示,进房流程却仍停在等待票据,页面会一直显示骨架屏:与关闭弹窗一样,放弃这次进入。 + // 弹窗已经不显示了,进房流程却还停在等待票据,页面会一直显示骨架屏。所以和关闭弹窗一样,放弃这次进入。 navigationStore.leaveRoom() roomEntryStore.reset() notificationStore.error('加入房间的窗口出错了,没有加入房间,再选一次房间。') diff --git a/web/src/app/connection-notices.ts b/web/src/app/connection-notices.ts index 53476f7..3327980 100644 --- a/web/src/app/connection-notices.ts +++ b/web/src/app/connection-notices.ts @@ -23,7 +23,7 @@ export function watchConnection(): void { notificationStore.error('与房间的连接中断了,正在自动重连。', 5000) } }) - // 握手时票据被拒绝会以 4401 断开,进房流程随后重新打开加入弹窗,不另外弹通知。 + // 握手时票据被拒绝,连接会以 4401 断开,随后进房流程会重新打开加入弹窗,所以不另外弹通知。 roomStore.errors.on((error) => { if (error.code === 'UNAUTHORIZED') return diff --git a/web/src/app/detail-panel.tsx b/web/src/app/detail-panel.tsx index 8387942..2e3f406 100644 --- a/web/src/app/detail-panel.tsx +++ b/web/src/app/detail-panel.tsx @@ -19,7 +19,7 @@ export function DetailPanel({ view, children }: { view: Panel | null, children: if (open || !shown || !panelRef.current) return const duration = Number.parseFloat(getComputedStyle(panelRef.current).animationDuration) * 1000 - // 动画被取消或面板被断点隐藏时不会触发 animationend,由定时器兜底卸载。 + // 动画被取消,或者面板被断点隐藏时,不会触发 animationend,所以由定时器兜底卸载。 const timer = setTimeout(setShown, duration ? duration + 50 : 0, null) return () => clearTimeout(timer) }, [open, shown]) diff --git a/web/src/app/error-boundary.tsx b/web/src/app/error-boundary.tsx index 97d8ae5..250b89b 100644 --- a/web/src/app/error-boundary.tsx +++ b/web/src/app/error-boundary.tsx @@ -4,7 +4,7 @@ import { EmptyState } from '@/shared/ui' interface ErrorBoundaryProps { children: ReactNode - // fallback 为函数时,须把 retryButton 挂到"重试"按钮上:出错时若焦点在区域内,边界通过它把焦点移到按钮上。 + // fallback 为函数时,要把 retryButton 挂到"重试"按钮上。出错时如果焦点在区域内,边界要通过 retryButton 把焦点移到按钮上。 fallback?: ReactNode | ((retry: () => void, retryButton: RefObject) => ReactNode) resetOn?: EventTarget // 只在值变化时重试一次。resetOn 在 Store 每次变化时都会重试,Store 变化频繁时,持续出错的区域会反复失败。 @@ -14,7 +14,7 @@ interface ErrorBoundaryProps { announce?: string } -// 出错状态保存在实例上。主内容和面板内容的边界位于以页面或视图为 key 的元素内,切换时随之重新挂载,出错状态即被清除。 +// 出错状态保存在实例上。主内容和面板内容的边界放在以页面或视图为 key 的元素里,切换页面或视图时边界会重新挂载,出错状态也就清除了。 export class ErrorBoundary extends Component { state = { failed: false } retryButton = createRef() @@ -28,7 +28,7 @@ export class ErrorBoundary extends Component { #narrow: MediaQueryList | null = null // 取消进入房间之后正在离开的房间:进房时的 ref、UUID 和别名。 @@ -37,10 +37,10 @@ class NavigationStore extends Store { this.#narrow.addEventListener('change', this.#onResize) const parsed = parseRoute(window.location) let route = this.#fit(parsed ?? lobby) - // 上次的房间记在所有标签页共用的 localStorage 里,后台又常在另一个标签页打开:后台不恢复房间,免得两个标签页同时放歌。 + // 打开后台时不恢复上次的房间。上次的房间记在所有标签页共用的 localStorage 里,后台又常在另一个标签页打开,恢复的话两个标签页会同时放歌。 if (!('ref' in route) && route.name !== 'admin') { const lastRoom = roomEntryStore.resume() - // 直接打开网站时进入上次的房间;刷新或者回到发现房间时停在原处。 + // 直接打开网站时进入上次的房间。刷新或者回到发现房间时停在原处。 if (lastRoom && fresh && parsed && formatRoute(parsed) === '/') route = { name: 'listen', ref: lastRoom, panel: null } } @@ -74,7 +74,7 @@ class NavigationStore extends Store { this.replace(list) } - // 不在房间里时,房间的页面没有地址,去发现房间。换页面时侧栏保持原样。 + // 不在房间里时,房间的页面没有地址,所以改去发现房间。换页面时侧栏保持原样。 open(page: 'lobby' | 'admin' | 'settings' | 'history' | 'playlists' | RoomPage): void { const { panel } = this.state.route if (page === 'lobby' || page === 'history') @@ -98,7 +98,7 @@ class NavigationStore extends Store { this.navigate({ ...this.state.route, panel }) } - // 取消进入房间:要在 roomEntryStore.reset() 之前调用,离开时靠进房的信息认出这个房间的地址。 + // 取消进入房间。leaveRoom 要在 roomEntryStore.reset() 之前调用,因为离开时要靠进房的信息认出这个房间的地址。 leaveRoom(): void { const { ref, room } = roomEntryStore.state this.#leaving = new Set([ref, room?.id, room?.slug].filter(value => typeof value === 'string')) @@ -135,13 +135,13 @@ class NavigationStore extends Store { } } - // 窄屏没有侧栏:打开带侧栏的地址时去掉它。 + // 窄屏没有侧栏,所以打开带侧栏的地址时去掉 panel。 #fit(route: Route): Route { return this.#narrow?.matches && route.panel ? { ...route, panel: null } : route } #show(next: Route): void { - // 关闭弹窗回到的记录地址不变:沿用原来的对象,订阅 route 的组件不必重新渲染。 + // 关闭弹窗后回到的记录地址没有变,所以沿用原来的 route 对象,订阅 route 的组件就不必重新渲染。 const route = formatRoute(next) === formatRoute(this.state.route) ? this.state.route : next if ('ref' in route && !isCurrentRoom(route.ref)) void roomEntryStore.enter(route.ref) diff --git a/web/src/app/route.ts b/web/src/app/route.ts index 4849d0b..3b34467 100644 --- a/web/src/app/route.ts +++ b/web/src/app/route.ts @@ -11,7 +11,7 @@ export type Panel = 'queue' | 'history' | 'chat' // panel 是宽屏右侧的侧栏,和当前页面相同时不显示。 export type Route = | { name: 'lobby', filter: string, panel: Panel | null } - // section 为 null 时地址是 /admin 或 /settings:宽屏显示第一个分类,窄屏显示分类列表。 + // section 为 null 时地址是 /admin 或 /settings,这时宽屏显示第一个分类,窄屏显示分类列表。 // q 和 page 只用于后台的房间和媒体,sort 只用于媒体。 | { name: 'admin', section: AdminSection | null, panel: Panel | null, q: string, page: number, sort: MediaSort } | { name: 'settings', section: SettingsSection | null, panel: Panel | null } @@ -22,7 +22,7 @@ export type Route | { name: 'search', ref: string, panel: Panel | null, query: SearchQuery } | { name: 'playlist', ref: string, panel: Panel | null, source: Source, id: string, page: number } | { name: 'shared-playlist', shareId: string, panel: Panel | null } - // 配对码在片段里,不进入请求和 Referer;写回地址时去掉,不留在历史记录中。 + // 配对码放在片段里,这样不会进入请求和 Referer。写回地址时去掉片段,配对码就不会留在历史记录里。 | { name: 'pair', code: string, panel: Panel | null } // GitHub 授权之后的落地页,片段里是 code=<兑换码> 或 error=<错误码>,同样不留在地址中。 | { name: 'github-login', fragment: string, panel: Panel | null } @@ -32,7 +32,7 @@ export type Page = Route['name'] const settingsSections: Record = { account: true, appearance: true, playback: true, performance: true, privacy: true } const adminSections: Record = { site: true, registration: true, users: true, rooms: true, sources: true, media: true } const mediaSorts: Record = { stored: true, idle: true, size: true } -// 与接口对媒体搜索词的长度限制一致,按码点计:手改或截断的链接里更长的搜索词会让每次读取都失败。 +// 和接口对媒体搜索词的长度限制一致,按码点计。手改或截断的链接里如果搜索词更长,每次读取都会失败。 const mediaQueryMaxLength = 100 function isOneOf(values: Record, value: string): value is T { @@ -52,7 +52,7 @@ function parsePanel(params: URLSearchParams): Panel | null { return panel === 'queue' || panel === 'history' || panel === 'chat' ? panel : null } -// 超出安全整数的页码写回地址时会变成另一个数,接口也不接受:当作第 1 页。 +// 超出安全整数的页码当作第 1 页,因为这样的页码写回地址时会变成另一个数,接口也不接受。 function parsePage(params: URLSearchParams): number { const page = Number(params.get('page')) return Number.isSafeInteger(page) && page > 1 ? page : 1 @@ -79,7 +79,7 @@ function writeQuery(query: SearchQuery, params: URLSearchParams): void { } if (query.keyword) params.set('q', query.keyword) - // 有关键词时总是写出来源:以后默认来源再变,旧链接的含义也不变。 + // 有关键词时总是写出来源,这样以后默认来源再变,旧链接的含义也不变。 if (query.keyword || query.source !== defaultSearchSource) params.set('source', query.source) if (query.view !== 'all') @@ -127,7 +127,7 @@ export function parseRoute({ pathname, search, hash }: Pick, canBack: boolean): 'back' | 'lobby' | 'stay' { if (!('ref' in route) || !leaving.has(route.ref)) return 'stay' diff --git a/web/src/app/style.css b/web/src/app/style.css index b59f8ad..8bcc846 100644 --- a/web/src/app/style.css +++ b/web/src/app/style.css @@ -1,5 +1,5 @@ @import 'tailwindcss'; -/* @theme 和 @utility 只在 Tailwind 入口样式的导入链中生效:改从 JS 导入,text-ui-*、muted 和 accent 就不会生成,构建也不报错。 */ +/* @theme 和 @utility 只在 Tailwind 入口样式的导入链里生效。改从 JS 导入 theme.css 的话,text-ui-*、muted 和 accent 就不会生成,构建也不会报错。 */ @import '@/shared/ui/theme.css'; @layer base { @@ -82,7 +82,7 @@ :root[data-view-direction='sink'] { --view-in: translateY(-8%); --view-out: translateY(100%); --view-swap: translateY(-10px); } .detail-panel { width: var(--detail-panel-width); flex-shrink: 0; position: relative; min-height: 0; display: flex; flex-direction: column; border: 0; border-radius: 12px; background: var(--color-panel); overflow: hidden; animation: detail-panel-open 240ms cubic-bezier(.2, .8, .2, 1) both; } .detail-panel-content { view-transition-name: detail-panel-content; width: var(--detail-panel-width); flex: 1; min-height: 0; display: flex; flex-direction: column; } -/* 只换关键帧里的位移:换 animation-name 会让面板在切换内容时重播打开动画。 */ +/* 只换关键帧里的位移,因为换 animation-name 会让面板在切换内容时重播打开动画。 */ .detail-panel { --detail-panel-offset: translateX(16px); } .detail-panel-sheet { --detail-panel-offset: translateY(100%); } .detail-panel-closing { pointer-events: none; animation-name: detail-panel-close; } @@ -94,7 +94,7 @@ from { width: var(--detail-panel-width); margin-left: 0; opacity: 1; transform: translateX(0); } to { width: 0; margin-left: -8px; opacity: 0; transform: var(--detail-panel-offset); } } -/* 视图过渡只移动面板内容:它的过渡分组裁剪在面板内,根快照不做动画,顶栏、主内容和播放器保持不动。 */ +/* 视图过渡只移动面板内容。面板内容的过渡分组裁剪在面板内,根快照不做动画,所以顶栏、主内容和播放器保持不动。 */ ::view-transition-group(root) { animation: none; overflow: clip; } ::view-transition-image-pair(root) { isolation: auto; } ::view-transition-old(root), ::view-transition-new(root) { mix-blend-mode: normal; animation: none; } @@ -103,7 +103,7 @@ ::view-transition-old(detail-panel-content), ::view-transition-new(detail-panel-content) { background: var(--color-panel); mix-blend-mode: normal; } ::view-transition-old(detail-panel-content) { animation: view-recede 300ms cubic-bezier(.2, .8, .2, 1) both; } ::view-transition-new(detail-panel-content) { animation: view-push-in 300ms cubic-bezier(.2, .8, .2, 1) both; } -/* 聊天在升起和沉下时都要压在列表上面,而新视图默认才在上层。 */ +/* 聊天在升起和沉下时都要压在列表上面,而默认只有新视图在上层。 */ :root[data-view-direction='rise']::view-transition-new(detail-panel-content), :root[data-view-direction='sink']::view-transition-old(detail-panel-content) { z-index: 1; border-radius: 12px 12px 0 0; box-shadow: 0 -8px 24px var(--color-shadow); } @keyframes view-recede { diff --git a/web/src/app/view-transition.ts b/web/src/app/view-transition.ts index 3a84db4..bb2f20e 100644 --- a/web/src/app/view-transition.ts +++ b/web/src/app/view-transition.ts @@ -4,7 +4,7 @@ import { performanceStore } from '@/features/settings' type ViewDirection = 'forward' | 'backward' | 'rise' | 'sink' -// 播放列表和播放历史是左右并排的 tab;聊天从播放栏升起,盖在它们上面。 +// 播放列表和播放历史是左右并排的 tab。聊天从播放栏升起,盖在这两个 tab 上面。 export function panelDirection(from: Panel, to: Panel): ViewDirection { if (to === 'chat') return 'rise' diff --git a/web/src/core/api/client.ts b/web/src/core/api/client.ts index 38dad83..b3cd1ad 100644 --- a/web/src/core/api/client.ts +++ b/web/src/core/api/client.ts @@ -16,7 +16,7 @@ export class ApiError extends Error { } } -// 被拒绝的请求不会重发,操作没有生效:调用方的提示应说明是哪项操作。 +// 被拒绝的请求不会重发,操作没有生效,所以调用方的提示要说明是哪项操作。 export class IdentityRenewedError extends ApiError { constructor() { super('刚才的操作没有生效,再试一次。', 'UNAUTHORIZED', 401) @@ -24,7 +24,7 @@ export class IdentityRenewedError extends ApiError { } } -// rejected:被拒绝的 token 已删除,新身份尚未取回;renewalFailed:新身份没有取回,之后需要成员身份的请求在没有 token 时会先获取一个。 +// rejected 表示被拒绝的 token 已经删除,新身份还没取回。renewalFailed 表示新身份没有取回,之后需要成员身份的请求在没有 token 时会先获取一个。 export type IdentityEvent = | { kind: 'rejected' } | { kind: 'renewed', member: Member } @@ -86,9 +86,9 @@ function isUnauthorized(error: unknown): boolean { return error instanceof ApiError && error.code === 'UNAUTHORIZED' } -// 被拒绝的请求不重发:它以原来的身份发出,是否以新身份重发应由用户决定。 +// 被拒绝的请求不重发,因为这个请求是以原来的身份发出的,要不要以新身份重发应由用户决定。 async function send(request: HttpRequest): Promise { - // 没有保存的 token 时,请求使用的是刚获取的 token,它被拒绝并不说明原来的身份已经失效。 + // 没有保存的 token 时,请求用的是刚获取的 token,这个 token 被拒绝并不说明原来的身份已经失效。 const stored = request.auth === 'member' ? loadMemberToken() : null try { return await sendOnce(request) @@ -152,7 +152,7 @@ function memberToken(): Promise { return creatingMember } -// 同一个 token 只更换一次身份:同时被拒绝的请求共用这一次,更换完成后才到达的拒绝也不再更换。 +// 同一个 token 只更换一次身份。同时被拒绝的请求共用这一次更换,更换完成后才到达的拒绝也不再触发更换。 let renewal: { rejected: string, done: Promise } | null = null function renewIdentity(rejected: string): Promise { @@ -161,7 +161,7 @@ function renewIdentity(rejected: string): Promise { return renewal.done } -// 更换过程中用 unrenewed 读取成员:新 token 也被拒绝时不再更换,避免循环。 +// 更换过程中用 unrenewed 读取成员,这样新 token 也被拒绝时不会再次更换,避免循环。 async function replaceIdentity(rejected: string): Promise { // 其他标签页可能已经更换了身份,只删除被拒绝的那个 token。票据属于被拒绝的身份,一并删除。 if (loadMemberToken() === rejected) { @@ -181,7 +181,7 @@ async function replaceIdentity(rejected: string): Promise { } // 只有本标签页已经用过 token、并且保存的 token 换成了另一个时才算身份变了。 -// 两个标签页同时创建游客时,对方先写入的 token 会被后写入的覆盖,此时本标签页还没有用过 token,或用的就是最终保存的 token,都不触发,避免互相刷新。 +// 两个标签页同时创建游客时,对方先写入的 token 会被后写入的覆盖。这时本标签页要么还没有用过 token,要么用的就是最终保存的 token,两种情况都不触发,避免互相刷新。 export function followMemberTokenChanges(onChange: () => void): void { window.addEventListener('storage', (event) => { if (event.key !== memberTokenKey) diff --git a/web/src/core/api/credentials.ts b/web/src/core/api/credentials.ts index a75de71..d0d9796 100644 --- a/web/src/core/api/credentials.ts +++ b/web/src/core/api/credentials.ts @@ -26,7 +26,7 @@ export function deleteTicket(roomId: string): void { localStorage.removeItem(ticketKeyPrefix + roomId) } -// 票据属于签发时的成员,换了身份之后都不能再用。倒序遍历:删除会让后面的键前移。 +// 票据属于签发时的成员,换了身份之后都不能再用。倒序遍历,因为删除会让后面的键前移。 export function deleteAllTickets(): void { for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i) diff --git a/web/src/core/brand/brand-store.ts b/web/src/core/brand/brand-store.ts index ba647c2..cb087c4 100644 --- a/web/src/core/brand/brand-store.ts +++ b/web/src/core/brand/brand-store.ts @@ -5,14 +5,14 @@ const nameKey = 'alisten.brand-name' const defaultName = 'Alisten' class BrandStore extends Store<{ name: string }> { - // 只采用最后一次读取或保存带回的名字:先发出的读取可能在后台改名之前就读到了旧的名字。 + // 只采用最后一次读取或保存带回的名字,因为先发出的读取可能在后台改名之前就读到了旧的名字。 #requests = 0 constructor() { super({ name: defaultName }) } - // 先用这台设备记下的品牌名,打开页面时才不会先闪一下默认的名字;取不到最新的品牌名时沿用它,不提示。 + // 先用这台设备记下的品牌名,打开页面时才不会先闪一下默认的名字。取不到最新的品牌名时就沿用记下的名字,不提示。 start(): void { this.setState({ name: localStorage.getItem(nameKey) || defaultName }) void this.load().catch(() => {}) diff --git a/web/src/core/errors/error-message.ts b/web/src/core/errors/error-message.ts index 6a7d883..fa1a08d 100644 --- a/web/src/core/errors/error-message.ts +++ b/web/src/core/errors/error-message.ts @@ -4,7 +4,7 @@ import { RequestError } from '@/core/realtime' export type ErrorTexts = Partial string)>> -// 这两个错误码的 message 写给接口调用方,带字段路径或协议版本,界面上换成用户能照着做的说法;其余错误码的 message 本身就是给用户看的。 +// 这两个错误码的 message 是写给接口调用方的,带着字段路径或协议版本,所以界面上换成用户能照着做的说法。其余错误码的 message 本身就是给用户看的。 const texts: ErrorTexts = { INVALID_ARGUMENT: '提交的内容不符合要求,请检查后再试。', CLIENT_OUTDATED: '应用已经更新,稍后会自动刷新页面。', @@ -15,7 +15,7 @@ export function errorText(code: ErrorCode, message: string, overrides: ErrorText return typeof text === 'function' ? text(message) : text ?? message } -// 没有错误码的 ApiError 和 RequestError 由前端构造,message 本来就是给用户看的;其他错误来自浏览器或程序缺陷,它们的 message 不给用户看。 +// 没有错误码的 ApiError 和 RequestError 由前端构造,message 本来就是给用户看的。其他错误来自浏览器或程序缺陷,它们的 message 不给用户看。 export function errorMessage(error: unknown, overrides?: ErrorTexts): string { if (!(error instanceof ApiError || error instanceof RequestError)) return '出错了,请稍后再试。' diff --git a/web/src/core/realtime/connection.test.ts b/web/src/core/realtime/connection.test.ts index 9049508..6fa7592 100644 --- a/web/src/core/realtime/connection.test.ts +++ b/web/src/core/realtime/connection.test.ts @@ -34,7 +34,7 @@ class FakeSocket extends EventTarget { this.sent.push(JSON.parse(message) as Sent) } - // 和浏览器一样,close 事件要到之后的任务才派发,这里不派发:测试用 lose() 模拟它。 + // 浏览器要到之后的任务才派发 close 事件,所以 close() 不派发。测试用 lose() 模拟这个 close 事件。 close(): void { this.readyState = FakeSocket.CLOSED } diff --git a/web/src/core/realtime/connection.ts b/web/src/core/realtime/connection.ts index 7da62d0..b723d60 100644 --- a/web/src/core/realtime/connection.ts +++ b/web/src/core/realtime/connection.ts @@ -4,8 +4,8 @@ import { reconnectDelay } from './backoff' import { CloseCode, PROTOCOL_VERSION } from './protocol.gen' const requestTimeoutMs = 60_000 -// 服务端从接受连接起 10 秒内准备不好握手响应就会以 4408 断开,但响应带着完整的房间快照,传给客户端的时间不受这个限制。 -// 这里留足余量,只用于服务端的关闭到不了客户端的情况。 +// 服务端从接受连接起 10 秒内准备不好握手响应,就会以 4408 断开。但握手响应带着完整的房间快照,传给客户端的时间不受这个限制。 +// helloTimeoutMs 留足了余量,只用于服务端的关闭到不了客户端的情况。 const helloTimeoutMs = 30_000 const pingIntervalMs = 5000 // 半开的连接上写入不会出错,要等 TCP 重传超时才会关闭,所以 ping 收不到回包就当作连接已失效。 diff --git a/web/src/core/realtime/protocol.gen.ts b/web/src/core/realtime/protocol.gen.ts index 9132a20..651f348 100644 --- a/web/src/core/realtime/protocol.gen.ts +++ b/web/src/core/realtime/protocol.gen.ts @@ -1,6 +1,6 @@ // Code generated by protocolgen from protocol/idl.json; DO NOT EDIT. -export const PROTOCOL_VERSION = 'ce462456c63356c6' +export const PROTOCOL_VERSION = 'd68525cd68ef691f' export interface APIKey { key: string diff --git a/web/src/core/room/replica.ts b/web/src/core/room/replica.ts index 1ff5d73..22d209b 100644 --- a/web/src/core/room/replica.ts +++ b/web/src/core/room/replica.ts @@ -48,7 +48,7 @@ export class RoomReplica { this.#waiting = true return { changed: false, notice: isNotice(event) ? this.#notice(event) : null, sync: true } } - // 快照不包含聊天,等待期间通知照常显示;状态事件以快照为准。 + // 快照不包含聊天,所以等待快照期间通知照常显示。状态事件则以快照为准。 return { changed: false, notice: isNotice(event) ? this.#notice(event) : null, sync: false } } diff --git a/web/src/features/admin/accounts.tsx b/web/src/features/admin/accounts.tsx index 8d5a5f9..54a75ce 100644 --- a/web/src/features/admin/accounts.tsx +++ b/web/src/features/admin/accounts.tsx @@ -49,7 +49,7 @@ export function Accounts() { const [username, setUsername] = useState('') const [lookup, setLookup] = useState({ phase: 'idle' }) const [confirming, setConfirming] = useState(false) - // 查找进行中又按了回车时,以最后一次为准:先发出的查找晚回来也不覆盖,结果和输入框里的用户名一致。 + // 查找进行中又按了回车时,以最后一次为准。先发出的查找晚回来也不会覆盖,这样显示的结果总对应最后一次查找的用户名。 const generationRef = useRef(0) const find = async () => { if (username === '') diff --git a/web/src/features/admin/admin-page.tsx b/web/src/features/admin/admin-page.tsx index 6218206..acc5e86 100644 --- a/web/src/features/admin/admin-page.tsx +++ b/web/src/features/admin/admin-page.tsx @@ -18,7 +18,7 @@ import './admin.css' export type AdminSection = 'site' | 'registration' | 'users' | 'rooms' | 'sources' | 'media' -// section 为 null 表示窄屏回到分类列表;没有给出的 q、page、sort 取默认值。 +// section 为 null 表示窄屏回到分类列表。没有给出的 q、page、sort 取默认值。 interface AdminView { section: AdminSection | null q?: string diff --git a/web/src/features/admin/admin-store.ts b/web/src/features/admin/admin-store.ts index 9df8c86..eac6719 100644 --- a/web/src/features/admin/admin-store.ts +++ b/web/src/features/admin/admin-store.ts @@ -28,7 +28,7 @@ class AdminStore extends Store<{ signedIn: boolean }> { this.setState({ signedIn: false }) } - // 失败时显示错误并返回 null;管理 token 被拒绝时退出登录。 + // 失败时显示错误并返回 null。管理 token 被拒绝时退出登录。 // 204 的接口成功时得到 undefined,调用方要与 null 比较,不能只看真假。 async call(request: () => Promise, texts?: ErrorTexts): Promise { try { @@ -41,7 +41,7 @@ class AdminStore extends Store<{ signedIn: boolean }> { } // 失败时抛出 Error,message 是给用户看的原因,由调用方就地显示,不弹通知。 - // 管理 token 被拒绝时例外:退出登录后这一页就不显示了,所以照常提示。 + // 管理 token 被拒绝时例外,照常提示,因为退出登录后这一页就不显示了。 async attempt(request: () => Promise, texts?: ErrorTexts): Promise { try { return await request() diff --git a/web/src/features/admin/admin.css b/web/src/features/admin/admin.css index 4c0e217..a7d7bec 100644 --- a/web/src/features/admin/admin.css +++ b/web/src/features/admin/admin.css @@ -22,7 +22,7 @@ .admin-hint { padding: 4px; color: var(--color-foreground-subtle); } .admin-row-error { color: var(--color-destructive); font-size: var(--text-ui-caption); } -/* 中性底色不用 surface:浅色主题里它和 card 同色,放在卡片上会看不见。 */ +/* 中性底色不用 surface,因为浅色主题里 surface 和 card 同色,放在卡片上会看不见。 */ .admin-status { display: inline-flex; flex-shrink: 0; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 999px; background: var(--color-card-hover); color: var(--color-foreground-subtle); font-size: var(--text-ui-sm); font-weight: 500; white-space: nowrap; } .admin-status[data-tone='success'] { background: var(--color-accent); color: var(--color-success); } .admin-status[data-tone='danger'] { background: color-mix(in srgb, var(--color-destructive) 14%, transparent); color: var(--color-destructive); } @@ -74,13 +74,13 @@ .media-sort { flex-shrink: 0; } .media-sort > .select-trigger { min-height: 36px; padding: 6px 12px; border-color: transparent; border-radius: 10px; background: var(--color-card-hover); font-size: var(--text-ui-caption); } .media-total { margin-left: auto; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); font-variant-numeric: tabular-nums; white-space: nowrap; } -/* 操作条不占位置,盖在筛选上面:勾选之后表格不会移动,Shift 连选时不会点错行。滚动时停在顶部,删除和取消一直够得着。 */ +/* 操作条不占位置,盖在筛选上面。这样勾选之后表格不会移动,Shift 连选时不会点错行。滚动时操作条停在顶部,删除和取消一直够得着。 */ .media-selection-anchor { position: sticky; top: 0; z-index: 3; height: 0; } .media-selection { position: absolute; top: 0; left: 50%; display: flex; align-items: center; gap: 6px; width: max-content; max-width: 100%; padding: 2px 2px 2px 16px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-toast); box-shadow: 0 10px 30px var(--color-shadow); transform: translateX(-50%); animation: fade-in 120ms ease both; } .media-selection-count { margin-right: 8px; font-size: var(--text-ui-caption); font-variant-numeric: tabular-nums; white-space: nowrap; } .media-selection > button { min-height: 30px; padding: 4px 14px; border-radius: 999px; font-size: var(--text-ui-caption); } -/* 选中的行有底色,圆角要裁掉它。菜单在顶层显示,不会被裁。 */ +/* 选中的行有底色,超出圆角的部分要裁掉。菜单在顶层显示,不会被裁。 */ .media-table { overflow: clip; transition: opacity 150ms; } .media-table[aria-busy='true'] { opacity: .6; } .media-table > ul { margin: 0; padding: 0; list-style: none; } @@ -88,7 +88,7 @@ .media-head { padding-block: 10px 8px; color: var(--color-foreground-subtlest); font-size: var(--text-ui-sm); } .media-row { min-height: 60px; border-top: 1px solid var(--color-card-hover); } .media-row[data-selected] { background: var(--color-accent); } -/* 整格都能点:复选框本身只有 16px。 */ +/* 整格都能点,因为复选框本身只有 16px。 */ .media-check { display: flex; align-items: center; align-self: stretch; margin: -8px -12px -8px -16px; padding: 0 12px 0 16px; cursor: pointer; } .media-song { display: flex; align-items: center; gap: 10px; min-width: 0; } .media-song > .artwork { width: 36px; border-radius: 6px; } @@ -111,11 +111,11 @@ .admin-room-access { grid-area: access; } .admin-room > .admin-menu-button { grid-area: menu; } .media-toolbar > .admin-search { flex: 1 1 100%; width: auto; } - /* 搜索框独占一行,操作条只盖住这一行:排序和总数照常显示,操作条下面不会空出一行。 */ + /* 搜索框独占一行,操作条只盖住这一行。这样排序和总数照常显示,操作条下面也不会空出一行。 */ .media-toolbar[data-hidden] > :not(.admin-search) { visibility: visible; } .media-head, .media-row { grid-template-columns: 16px minmax(0, 1fr) 36px; } .media-cell { display: none; } - /* 窄的时候封面让出位置:标题下面两行要放下大小、存储时间和最近播放。 */ + /* 窄的时候封面让出位置,因为标题下面的两行要放下大小、存储时间和最近播放。 */ .media-song > .artwork { display: none; } .media-song-copy .media-narrow { display: inline; } .media-song-copy > .media-narrow { display: block; } diff --git a/web/src/features/admin/github-app.tsx b/web/src/features/admin/github-app.tsx index 9e35a23..2a60a1f 100644 --- a/web/src/features/admin/github-app.tsx +++ b/web/src/features/admin/github-app.tsx @@ -116,7 +116,7 @@ export function GithubApp() { - {/* 关闭之后重新读取配置,整个表单换成骨架屏:焦点落到分类标题。 */} + {/* 关闭之后重新读取配置,整个表单换成骨架屏,所以焦点落到分类标题。 */} {closing && ( store.state.list?.items.map(item => item.key const selected = (store: MediaStore) => [...store.state.selected].sort() const messages = () => notificationStore.state.notifications.map(n => n.message) -// 等作废的读取走完:它不改变任何状态,没有可以等待的信号。 +// 等作废的读取走完。作废的读取不改变任何状态,所以没有可以等待的信号。 const drain = () => new Promise(resolve => setTimeout(resolve, 20)) function releaseAll(): void { diff --git a/web/src/features/admin/media-store.ts b/web/src/features/admin/media-store.ts index faae7ec..7a7d9c5 100644 --- a/web/src/features/admin/media-store.ts +++ b/web/src/features/admin/media-store.ts @@ -30,10 +30,10 @@ export function fallbackPage(list: AdminListMediaResponse): number | null { return Math.max(1, Math.ceil(list.total / list.pageSize)) } -// 每打开一次媒体页新建一个:离开再回来时重新读取。 +// 每打开一次媒体页就新建一个 MediaStore,这样离开再回来时会重新读取。 export class MediaStore extends Store { #query: MediaQuery | null = null - // 只有最后发起的读取能写入:换了参数、重试或删除之后,此前的读取作废。 + // 只有最后发起的读取能写入。换了参数、重试或删除之后,此前的读取作废。 #generation = 0 #anchor: string | null = null @@ -41,7 +41,7 @@ export class MediaStore extends Store { super({ list: null, loadedAt: 0, loading: false, error: null, selected: new Set() }) } - // 地址里的参数变化时调用;参数和上一次相同时不重新读取。换了参数就清空选择。 + // 地址里的参数变化时调用。参数和上一次相同时不重新读取,换了参数就清空选择。 show(query: MediaQuery): void { const current = this.#query if (current && current.q === query.q && current.sort === query.sort && current.page === query.page) @@ -74,7 +74,7 @@ export class MediaStore extends Store { this.setState({ selected: new Set() }) } - // 失败时原因已经通过通知显示,返回 null。无论成败都重新读取当前页,读完才返回:界面不会再显示已经删掉的歌,中途出错时出错之前删掉的也一样。 + // 失败时原因已经通过通知显示,返回 null。无论成败都重新读取当前页,读完才返回。这样界面不会再显示已经删掉的歌,中途出错时,出错之前删掉的歌也不会再显示。 async remove(keys: string[]): Promise { const result = await adminStore.call(() => api.adminRemoveMedia({ media: keys.map(key => ({ key })) })) await this.#load() diff --git a/web/src/features/admin/media-view.ts b/web/src/features/admin/media-view.ts index 61f5870..76ef0a7 100644 --- a/web/src/features/admin/media-view.ts +++ b/web/src/features/admin/media-view.ts @@ -7,7 +7,7 @@ const minute = 60 * 1000 const hour = 60 * minute const day = 24 * hour -// 地址里的搜索词变了之后输入框显示什么:它就是输入框自己提交的值(去掉了首尾空白)时保留原样的输入,比如末尾刚打的空格;被别处改掉时(清除搜索、后退)换成地址里的。 +// 地址里的搜索词变了之后,决定输入框显示什么。新的搜索词就是输入框自己提交的值(去掉了首尾空白)时,保留原样的输入,比如末尾刚打的空格。搜索词被别处改掉时(清除搜索、后退),换成地址里的搜索词。 export function searchDraft(draft: string, q: string): string { return draft.trim() === q ? draft : q } @@ -63,7 +63,7 @@ export function mediaByline(item: StoredMedia): string { return item.artist ? `${item.artist} · ${origin}` : origin } -// 不存在的歌(比如在别的标签页里已经删掉了)也算删除:对管理员来说结果一样。 +// 不存在的歌(比如在别的标签页里已经删掉了)也算删除,因为对管理员来说结果一样。 export function removalMessage(requested: number, inUse: number): string { const busy = `${inUse} 首正在房间里播放,没有删除` if (inUse === requested) diff --git a/web/src/features/admin/persistent-rooms.tsx b/web/src/features/admin/persistent-rooms.tsx index 449c1c1..f4c7e9a 100644 --- a/web/src/features/admin/persistent-rooms.tsx +++ b/web/src/features/admin/persistent-rooms.tsx @@ -20,7 +20,7 @@ function enterRoom(room: RoomListing, onEntered: (ref: string) => void): void { onEntered(ref) } -// q 和 page 来自地址:改搜索词时回到第 1 页。 +// q 和 page 来自地址。改搜索词时回到第 1 页。 export function PersistentRooms({ q, page, onFilter, onEntered }: { q: string page: number @@ -33,7 +33,7 @@ export function PersistentRooms({ q, page, onFilter, onEntered }: { const [editing, setEditing] = useState(null) const [deleting, setDeleting] = useState(null) const [keyOf, setKeyOf] = useState(null) - // 删掉的房间之后焦点落到哪一行:null 表示落到标题。 + // 删掉房间之后焦点要落到的那一行,null 表示落到标题。 const nextFocusRef = useRef(null) useEffect(() => { let active = true @@ -166,7 +166,7 @@ export function PersistentRooms({ q, page, onFilter, onEntered }: { onConfirm={async () => { if (await adminStore.call(() => api.adminDeleteRoom(deleting.id)) === null) return - // 先在本地去掉这个房间,对话框关闭时那一行已经不在了,焦点才能落到下一行;之后再重新读取。 + // 先在本地去掉这个房间,这样对话框关闭时那一行已经不在了,焦点才能落到下一行。之后再重新读取。 const remaining = rooms?.filter(room => room.id !== deleting.id) ?? [] const next = filterRooms(remaining, q, page) // 删空了这一页时显示的是上一页,那里没有“下一行”。 diff --git a/web/src/features/admin/qr-login.ts b/web/src/features/admin/qr-login.ts index 7e3a956..94fcaa0 100644 --- a/web/src/features/admin/qr-login.ts +++ b/web/src/features/admin/qr-login.ts @@ -28,7 +28,7 @@ function pending(status: QRLoginStatus): boolean { export function nextQrLogin(state: QrLoginState, event: QrLoginEvent): QrLoginStep { if (!pending(state.status)) { - // 本机判定过期时可能还有一次查询尚未返回:它返回 success 时服务端已经保存了登录状态,仍按成功处理。 + // 本机判定过期时,可能还有一次查询没有返回。这次查询返回 success 时,服务端已经保存了登录状态,所以仍按成功处理。 if (state.status === 'expired' && event.kind === 'checked' && event.result?.status === 'success') return { state: { ...state, status: 'success', message: event.result.message }, polling: false } return { state, polling: false } diff --git a/web/src/features/admin/registration.tsx b/web/src/features/admin/registration.tsx index 669943d..f66ad89 100644 --- a/web/src/features/admin/registration.tsx +++ b/web/src/features/admin/registration.tsx @@ -6,7 +6,7 @@ import { adminStore } from './admin-store' import { GithubApp } from './github-app' import { LoadFailed, SectionHead } from './section-head' -// 保存期间不禁用开关:禁用会让它失去焦点,键盘用户就找不到它了。 +// 保存期间不禁用开关,因为禁用会让开关失去焦点,键盘用户就找不到它了。 function OpenRegistrationRow({ open, onSaved }: { open: boolean, onSaved: (open: boolean) => void }) { const switchId = useId() const descriptionId = useId() diff --git a/web/src/features/admin/removal-focus.ts b/web/src/features/admin/removal-focus.ts index ad887e9..37e4f2b 100644 --- a/web/src/features/admin/removal-focus.ts +++ b/web/src/features/admin/removal-focus.ts @@ -1,4 +1,4 @@ -// 行是带 data-key 的 li,焦点落到它的第一个控件上;没有这一行时落到分类标题。 +// 行是带 data-key 的 li,焦点落到这一行的第一个控件上。没有这一行时落到分类标题。 export function rowOrHeading(headingId: string, key: string | null): HTMLElement | null { const heading = document.getElementById(headingId) const row = key === null ? null : heading?.closest('section')?.querySelector(`li[data-key="${CSS.escape(key)}"] :is(input, button)`) diff --git a/web/src/features/admin/room-api-key-dialog.tsx b/web/src/features/admin/room-api-key-dialog.tsx index 35e2ded..a5b8895 100644 --- a/web/src/features/admin/room-api-key-dialog.tsx +++ b/web/src/features/admin/room-api-key-dialog.tsx @@ -6,7 +6,7 @@ import { formatDateTime } from '@/shared/lib/time' import { ConfirmDialog, Modal, notificationStore } from '@/shared/ui' import { adminStore } from './admin-store' -// 非安全上下文(比如局域网的明文 HTTP)没有剪贴板接口,用户也可能拒绝授权:这时返回 false,由调用方让用户手动复制。 +// 非安全上下文(比如局域网的明文 HTTP)没有剪贴板接口,用户也可能拒绝授权。这时返回 false,由调用方让用户手动复制。 async function copyApiKey(key: string): Promise { try { await navigator.clipboard.writeText(key) @@ -37,7 +37,7 @@ export function RoomApiKeyDialog({ room, onClose }: { room: RoomListing, onClose active = false } }, [room.id, attempt]) - // 不用 autoFocus:确认弹窗卸载时会把焦点还给打开它之前聚焦的元素(比如这个弹窗的关闭按钮),晚于 autoFocus;这个 effect 在那之后运行。 + // 不用 autoFocus,因为确认弹窗卸载时会把焦点还给打开它之前聚焦的元素(比如这个弹窗的关闭按钮),时间晚于 autoFocus。这个 effect 在还回焦点之后才运行。 useEffect(() => { if (key !== null) inputRef.current?.focus() @@ -69,7 +69,7 @@ export function RoomApiKeyDialog({ room, onClose }: { room: RoomListing, onClose inputRef.current?.select() notificationStore.info('没能写入剪贴板,请手动复制选中的 Key。', 8000) } - // 确认弹窗与这个弹窗并列,不放进 Modal 里:React 沿组件树传递 dialog 的 cancel 事件,在确认弹窗里按 Esc 会把两个弹窗一起关掉。 + // 确认弹窗与这个弹窗并列,不放进 Modal 里。React 沿组件树传递 dialog 的 cancel 事件,放进去的话,在确认弹窗里按 Esc 会把两个弹窗一起关掉。 return ( <> diff --git a/web/src/features/admin/section-head.tsx b/web/src/features/admin/section-head.tsx index e15ac12..11a70c2 100644 --- a/web/src/features/admin/section-head.tsx +++ b/web/src/features/admin/section-head.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { CloudOff } from 'lucide-react' -// 分类的标题要和内容一起立即渲染,加载中也不例外:从分类栏切换过来之后,焦点要落在它上面。 +// 分类的标题要和内容一起立即渲染,加载中也不例外,因为从分类栏切换过来之后,焦点要落在标题上。 export function SectionHead({ id, title, description, children }: { id: string, title: string, description?: string, children?: ReactNode }) { return (
diff --git a/web/src/features/admin/site-settings.tsx b/web/src/features/admin/site-settings.tsx index 722eb5c..2bc469a 100644 --- a/web/src/features/admin/site-settings.tsx +++ b/web/src/features/admin/site-settings.tsx @@ -9,7 +9,7 @@ import { LoadFailed, SectionHead } from './section-head' async function saveBrandName(name: string): Promise { const saved = await adminStore.attempt(() => api.adminUpdateSite({ brandName: name }), { INVALID_ARGUMENT: '品牌名要有 1 到 20 个字,不能含换行、制表符等特殊字符。' }) - // 显示服务端存下的名字:浏览器和服务端去掉首尾空白的规则不完全相同。 + // 显示服务端存下的名字,因为浏览器和服务端去掉首尾空白的规则不完全相同。 brandStore.setName(saved.brandName) } diff --git a/web/src/features/admin/source-account-view.test.ts b/web/src/features/admin/source-account-view.test.ts index b69bfd3..b14b124 100644 --- a/web/src/features/admin/source-account-view.test.ts +++ b/web/src/features/admin/source-account-view.test.ts @@ -35,7 +35,7 @@ describe('sourceAccountView', () => { } }) -// 后台分类栏的“音源”是否显示红点:查询中和查询失败时不知道状态,不显示。 +// 后台分类栏的“音源”是否显示红点。查询中和查询失败时不知道状态,所以不显示。 describe('needsSignIn', () => { for (const tc of cases) { it(tc.name, () => { diff --git a/web/src/features/admin/stored-media.tsx b/web/src/features/admin/stored-media.tsx index 153e169..1336f8e 100644 --- a/web/src/features/admin/stored-media.tsx +++ b/web/src/features/admin/stored-media.tsx @@ -22,7 +22,7 @@ const sortOptions: (SelectOption & { value: MediaSort })[] = [ { value: 'size', label: '占用最大' }, ] -// 输入停顿之后才改写地址,输入法组字期间不算停顿:不拿拼音字母去搜索。 +// 输入停顿之后才改写地址。输入法组字期间不算停顿,这样不会拿拼音字母去搜索。 function MediaSearch({ q, onSearch }: { q: string, onSearch: (q: string) => void }) { const [draft, setDraft] = useState(q) const [seenQ, setSeenQ] = useState(q) @@ -64,7 +64,7 @@ export function MediaPage({ q, sort, page, onNavigate }: MediaQuery & { onNaviga const error = useStore(store, s => s.error) const selected = useStore(store, s => s.selected) const [deleting, setDeleting] = useState(null) - // 删掉的歌之后焦点落到哪一行:null 表示落到标题。 + // 删掉歌之后焦点要落到的那一行,null 表示落到标题。 const nextFocusRef = useRef(null) useEffect(() => store.show({ q, sort, page }), [store, q, sort, page]) @@ -76,7 +76,7 @@ export function MediaPage({ q, sort, page, onNavigate }: MediaQuery & { onNaviga navigate({ q, sort, page: fallback }) }, [fallback, q, sort]) - // 页码在表格下面,换页之后回到顶部。用 nearest:start 会把外层不该滚动的页面框架也一起滚上去。 + // 页码条在表格下方,换页后把标题滚回视野里。用 nearest 而不是 start,因为 start 会把外层本不该滚动的页面框架也一起滚上去。 const goToPage = (next: number) => { onNavigate({ q, sort, page: next }) document.getElementById('admin-media-title')?.scrollIntoView({ block: 'nearest' }) diff --git a/web/src/features/admin/youtube-cookies.tsx b/web/src/features/admin/youtube-cookies.tsx index 4ed046a..834dee7 100644 --- a/web/src/features/admin/youtube-cookies.tsx +++ b/web/src/features/admin/youtube-cookies.tsx @@ -17,7 +17,7 @@ function YouTubeCookiesDialog({ onSaved, onClose }: { onSaved: () => void, onClo return setBusy(true) try { - // 空白和超过 64 KiB 的内容不会提交,INVALID_ARGUMENT 只剩认不出格式,这时的 message 是写给用户的。 + // 空白和超过 64 KiB 的内容不会提交,所以 INVALID_ARGUMENT 只剩认不出格式这一种原因,这时的 message 是写给用户的。 const saved = await adminStore.call(() => api.adminSetYoutubeCredential({ cookies }), { INVALID_ARGUMENT: message => message }) if (saved !== null) onSaved() @@ -118,7 +118,7 @@ export function YouTubeCookies() { onClose={() => setEditing(false)} /> )} - {/* 清除之后“清除”按钮不再显示,重新读取期间“填写 cookies”也不能点:焦点落到分类标题。 */} + {/* 清除之后“清除”按钮不再显示,重新读取期间“填写 cookies”也不能点,所以焦点落到分类标题。 */} {clearing && ( current.startsWith(draft) ? current.slice(draft.length) : current) scrollToBottom() } diff --git a/web/src/features/chat/chat-store.ts b/web/src/features/chat/chat-store.ts index d4362ca..9de8aa5 100644 --- a/web/src/features/chat/chat-store.ts +++ b/web/src/features/chat/chat-store.ts @@ -9,7 +9,7 @@ type ChatItem = | { kind: 'message', key: string, member: Member, text: string, atMs: number } | { kind: 'activity', key: string, member: Member | null, text: string, atMs: number } -// 不保留聊天记录:只显示这次进入房间之后收到的消息和房间动态,换房间时清空。 +// 不保留聊天记录。只显示这次进入房间之后收到的消息和房间动态,换房间时清空。 class ChatStore extends Store<{ items: ChatItem[] }> { #roomId: string | null = null diff --git a/web/src/features/history/history-store.ts b/web/src/features/history/history-store.ts index d10faa8..b19d169 100644 --- a/web/src/features/history/history-store.ts +++ b/web/src/features/history/history-store.ts @@ -21,7 +21,7 @@ export interface SearchSettings { } interface HistoryState extends SearchSettings { - // 播放历史在服务端;还没有读到时为 null。 + // 播放历史保存在服务端,还没有读到时为 null。 listens: MyListen[] | null // 最近一次读取失败的原因,读取成功后为 null。 error: string | null @@ -49,7 +49,7 @@ function sameTrack(a: Track, b: Track): boolean { class HistoryStore extends Store { #playing = '' #pages = 0 - // 只有最后发起的读取能写入:之后又读过一次,或者删除、清空、换了身份之后,此前的读取作废。 + // 只有最后发起的读取能写入。之后又读过一次,或者删除、清空、换了身份之后,此前的读取作废。 #generation = 0 #reading = 0 @@ -74,7 +74,7 @@ class HistoryStore extends Store { identityEvents.on(this.#onIdentity) } - // 每打开一个历史页读取一次;页面开着时,服务端在开播时写入历史,所以正在播放的歌一变就重新读取。返回的函数在页面关闭时调用。 + // 每打开一个历史页读取一次。服务端在开播时写入历史,所以页面开着时,正在播放的歌一变就重新读取。返回的函数在页面关闭时调用。 open(): () => void { this.#pages++ void this.load() @@ -132,7 +132,7 @@ class HistoryStore extends Store { setTimeout(() => URL.revokeObjectURL(url)) } - // 只记录用户发起的搜索(提交、点最近搜索、换来源和类别);翻页、推荐、打开歌单,以及前进、后退还原的搜索都不计入。 + // 只记录用户发起的搜索(提交、点最近搜索、换来源和类别)。翻页、推荐、打开歌单,以及前进、后退还原的搜索都不计入。 recordSearch(query: string, source: SearchSource, category: SearchCategory): void { if (this.state.searchesEnabled) this.#saveSearches(recordSearch(this.state.searches, { query, source, category, searchedAtMs: Date.now() }, this.state.searchLimit)) @@ -161,7 +161,7 @@ class HistoryStore extends Store { } } - // 改动之前发起的读取可能带着改动前的历史返回,作废它们;有读取被作废时再读一次,免得错过其间开播的歌。 + // 改动之前发起的读取可能带着改动前的历史返回,所以要作废这些读取。有读取被作废时再读一次,免得错过其间开播的歌。 #changed(listens: MyListen[]): void { this.#generation++ this.setState({ listens }) @@ -178,8 +178,8 @@ class HistoryStore extends Store { void this.load() } - // 成员 token 被拒绝时页面不重新加载:旧身份的历史不能留在页面上,新的游客还没有播放历史。新身份也可能是其他标签页 - // 换来的、已经有历史的游客,所以换好之后页面开着就重新读取。 + // 成员 token 被拒绝时页面不会重新加载。旧身份的历史不能留在页面上,新的游客又还没有播放历史,所以直接清空。 + // 新身份也可能是其他标签页换来的游客,这样的游客可能已经有播放历史,所以换好之后,页面开着就重新读取。 #onIdentity = (event: IdentityEvent): void => { if (event.kind === 'rejected') { this.#generation++ @@ -190,7 +190,7 @@ class HistoryStore extends Store { } } - // 其他标签页写入搜索记录时同步更新,本页之后的写入才不会覆盖它写入的记录。 + // 其他标签页写入搜索记录时,本页跟着更新,这样本页之后的写入才不会覆盖那个标签页写入的记录。 #onStorage = (event: StorageEvent): void => { if (event.key === searchesKey) this.setState({ searches: parseSearches(event.newValue, this.state.searchLimit) }) diff --git a/web/src/features/lyrics/lrc.ts b/web/src/features/lyrics/lrc.ts index 05f2255..18908c4 100644 --- a/web/src/features/lyrics/lrc.ts +++ b/web/src/features/lyrics/lrc.ts @@ -15,7 +15,7 @@ export interface LyricLine { const timeTag = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g const yrcLineTag = /^\[(\d+),(\d+)\]/ const yrcWordTag = /\((\d+),(\d+),\d+\)/g -// 逐字歌词没有空白行,要靠行尾和下一行的间隔判断间奏。间隔太短就算作换气:清除高亮再点亮会闪烁。 +// 逐字歌词没有空白行,要靠行尾和下一行的间隔判断间奏。间隔太短就算作换气,否则清除高亮再点亮会闪烁。 const interludeGapMs = 3000 const maxTranslationGapMs = 500 const minInterludeMs = 7000 @@ -50,7 +50,7 @@ export function parseYrc(yrc: string): LyricLine[] { const text = row.slice(tag.index + tag[0].length, tags[i + 1]?.index) if (text.trim()) words.push({ startMs: Number(tag[1]), durationMs: Number(tag[2]), text }) - // 字独立绘制:只含空格的字单独成字时,它两侧的空格不会合并,词间距会变成两倍。 + // 字是独立绘制的。只含空格的字如果单独成字,它两侧的空格不会合并,词间距会变成两倍。 else if (words.length) words[words.length - 1].text += text } @@ -70,13 +70,13 @@ export function wordProgress({ startMs, durationMs }: LyricWord, positionMs: num return Math.min(Math.max((positionMs - startMs) / Math.max(durationMs, 1), 0), 1) } -// 唱得久的短词才强调:短音节一闪而过看不出来,长词放大会挤到相邻的字。 +// 唱得久的短词才强调,因为短音节一闪而过看不出来,长词放大会挤到相邻的字。 export function wordEmphasis({ durationMs, text }: LyricWord): number { const { length } = text.trim() return durationMs > 1000 && length > 0 && length < 8 ? Math.min(durationMs, 2000) / 1000 - 1 : 0 } -// 由翻译找行,而不是由行找翻译:否则没有翻译的行会借用相邻行的翻译。 +// 由翻译找行,而不是由行找翻译,否则没有翻译的行会借用相邻行的翻译。 export function withTranslations(lines: LyricLine[], translations: LyricLine[]): LyricLine[] { const sung = lines.filter(line => line.text) const gap = (a: LyricLine, b: LyricLine) => Math.abs(a.timeMs - b.timeMs) @@ -96,7 +96,7 @@ export function withTranslations(lines: LyricLine[], translations: LyricLine[]): }) } -// 长间奏用点倒计时:每个点是一个不带文字的字,绘制时和逐字歌词走同一条路径。 +// 长间奏用点倒计时。每个点是一个不带文字的字,绘制时和逐字歌词走同一条路径。 export function withInterludes(lines: LyricLine[]): LyricLine[] { const first = lines.find(line => line.text) const withIntro = first && lines[0].timeMs > 0 && first.timeMs >= minInterludeMs ? [{ timeMs: 0, text: '' }, ...lines] : lines diff --git a/web/src/features/lyrics/lyrics-store.ts b/web/src/features/lyrics/lyrics-store.ts index 00a0344..ad8b83a 100644 --- a/web/src/features/lyrics/lyrics-store.ts +++ b/web/src/features/lyrics/lyrics-store.ts @@ -23,7 +23,7 @@ class LyricsStore extends Store<{ lines: LyricLine[], index: number }> { return this.#track = track const lines = nowPlaying ? buildLines(nowPlaying.lyric, nowPlaying.lyricWords, nowPlaying.lyricTrans) : [] - // 换歌时新歌尚未开始播放,播放器给出的仍是上一首的位置:先按歌曲开头计算,开始播放后再由每帧的位置更新。 + // 换歌时新歌还没开始播放,播放器给出的仍是上一首的位置。所以先按歌曲开头计算,开始播放后再由每帧的位置更新。 this.setState({ lines, index: lineIndexAt(lines, 0) }) } diff --git a/web/src/features/lyrics/lyrics.css b/web/src/features/lyrics/lyrics.css index f7ab6c1..d2bb400 100644 --- a/web/src/features/lyrics/lyrics.css +++ b/web/src/features/lyrics/lyrics.css @@ -6,7 +6,7 @@ .lyrics-scroll:not(:has(.empty-state))::before, .lyrics-scroll:not(:has(.empty-state))::after { content: ''; display: block; height: 50%; } /* 用 transform 而不是 font-size 区分层级,切换时不触发重排。 */ .lyric-line { max-width: 880px; margin: 0 auto; padding: 8px 32px; line-height: 1.5; text-align: center; color: var(--color-foreground-subtle); opacity: .78; font-weight: 600; overflow-wrap: anywhere; transform: translateY(4px) scale(.7); transform-origin: center; transition: transform 420ms cubic-bezier(.2, .8, .2, 1), opacity 300ms ease, color 300ms ease; } -/* 分数缩放下,相邻行进出合成层时文字会抖动。当前行不能这样固定:Chrome 不会按放大后的比例重新栅格化,文字会变糊。 */ +/* 分数缩放下,相邻行进出合成层时文字会抖动。当前行不能这样固定,因为 Chrome 不会按放大后的比例重新栅格化,文字会变糊。 */ .lyric-line.near-lyric { opacity: .9; transform: translateY(2px) scale(.8); will-change: transform; } .lyric-line.active-lyric { color: var(--color-foreground); opacity: 1; transform: translateY(0) scale(1.18); } /* inline-block 才能位移和缩放,只有放大的字需要滤镜。 */ diff --git a/web/src/features/lyrics/lyrics.tsx b/web/src/features/lyrics/lyrics.tsx index 7021c21..2b4341e 100644 --- a/web/src/features/lyrics/lyrics.tsx +++ b/web/src/features/lyrics/lyrics.tsx @@ -31,7 +31,7 @@ function neighborLine(node: Element, side: 'previousElementSibling' | 'nextEleme return neighbor?.classList.contains('lyric-interlude') ? neighbor[side] : neighbor } -// 没有点的空白行不渲染节点:当前行为这种空白行时,滚动到前后两行的中间。 +// 没有点的空白行不渲染节点。当前行是这种空白行时,滚动到前后两行的中间。 function centerLine(container: HTMLElement, nodes: Map, index: number, smooth: boolean): void { const scrolledFrom = container.scrollTop let before: HTMLElement | undefined @@ -54,7 +54,7 @@ function centerLine(container: HTMLElement, nodes: Map, ind rippleLines(container, nodes, container.scrollTop - scrolledFrom) } -// 容器已经滚到位,每行再从原来的位置依次移过来。动画是叠加的:上一次还没走完时换行,不会跳回起点。 +// 容器已经滚到位,每行再从原来的位置依次移过来。动画是叠加的,所以上一次还没走完时换行,也不会跳回起点。 function rippleLines(container: HTMLElement, nodes: Map, distance: number): void { const reach = Math.abs(distance) // 一次跨过整屏时(比如刚进房间)没有“下一行”可言,直接到位。 @@ -80,7 +80,7 @@ export function Lyrics() { useEffect(() => { const container = scrollRef.current! const nodes = new Map(Array.from(container.querySelectorAll('[data-lyric-index]'), node => [Number(node.dataset.lyricIndex), node])) - // React 会复用 key 相同的节点,需要先清除上一首歌留下的高亮。 + // React 会复用 key 相同的节点,所以要先清除上一首歌留下的高亮。 for (const node of nodes.values()) { node.classList.remove('active-lyric', 'near-lyric') node.removeAttribute('aria-current') diff --git a/web/src/features/playback/playback.css b/web/src/features/playback/playback.css index aa16979..7b0dbb8 100644 --- a/web/src/features/playback/playback.css +++ b/web/src/features/playback/playback.css @@ -33,7 +33,7 @@ .player-tools .volume-slider { width: 60px; } } @media (max-width: 767px) { - /* 窄屏的播放器栏由内容撑开高度;内容比封面矮时(比如出错提示),仍保持封面加上下内边距的高度。 */ + /* 窄屏的播放器栏由内容撑开高度。内容比封面矮时(比如出错提示),仍保持封面加上下内边距的高度。 */ .player-bar { --player-artwork: 44px; --player-padding-y: 6px; display: flex; position: relative; gap: 10px; padding: var(--player-padding-y) 10px; min-height: calc(var(--player-artwork) + 2 * var(--player-padding-y)); border: 0; border-radius: 8px; background: var(--color-frame-surface); } .player-artwork, .player-artwork .artwork { border-radius: 6px; } .player-artwork .artwork { width: var(--player-artwork); } diff --git a/web/src/features/playback/player-bar.tsx b/web/src/features/playback/player-bar.tsx index 04cc463..c5a3271 100644 --- a/web/src/features/playback/player-bar.tsx +++ b/web/src/features/playback/player-bar.tsx @@ -71,7 +71,7 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp const artist = meta?.artist || '未知艺术家' const source = nowPlaying ? sourceName(nowPlaying.entry.track) : undefined const note = nowPlaying?.entry.by ? `${nowPlaying.entry.by.name} 点的歌` : undefined - // 记下打开时的歌:对话框开着时换了歌,或者这首播完了,勾选仍然作用于打开时的那首。 + // 记下打开时的歌。这样对话框开着时换了歌,或者这首播完了,勾选仍然作用于打开时的那首。 const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null) return (
diff --git a/web/src/features/playback/player-store.ts b/web/src/features/playback/player-store.ts index e32353a..7351603 100644 --- a/web/src/features/playback/player-store.ts +++ b/web/src/features/playback/player-store.ts @@ -56,7 +56,7 @@ class PlayerStore extends Store<{ needsGesture: boolean }> { this.#onRoom() } catch (error) { - // 挂载中途失败时必须清理:否则房间的订阅仍会在已移除的
{body} - {/* 选中数的播报常驻在页面上:选择栏要等选中第一首才出现,跟着它出现的播报区域读屏软件不会播报。 */} + {/* 选中数的播报常驻在页面上,因为选择栏要等选中第一首才出现,而跟着选择栏一起出现的播报区域,读屏软件不会播报。 */}

{shown && selected.length > 0 ? `已选 ${selected.length} 首` : ''}

{!connected && shown?.length ?

进入房间之后才能点歌。

: null} {playlist && shown && selected.length > 0 && ( diff --git a/web/src/features/playlists/playlist-date.test.ts b/web/src/features/playlists/playlist-date.test.ts index 226fbcb..9ec7a2e 100644 --- a/web/src/features/playlists/playlist-date.test.ts +++ b/web/src/features/playlists/playlist-date.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { formatPlaylistDate } from './playlist-date' -// 用本地时间构造时刻:断言与运行测试的时区无关。 +// 用本地时间构造时刻,这样断言与运行测试的时区无关。 function at(year: number, month: number, day: number, hour = 0, minute = 0): number { return new Date(year, month - 1, day, hour, minute).getTime() } diff --git a/web/src/features/playlists/playlist-file.ts b/web/src/features/playlists/playlist-file.ts index 641aa40..3254a6b 100644 --- a/web/src/features/playlists/playlist-file.ts +++ b/web/src/features/playlists/playlist-file.ts @@ -40,7 +40,7 @@ function text(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } -// 文件可以随意改写:元数据的类型不对时当作没有,由服务端决定用不用;音源和 ID 不对就认不出是哪首歌,整个文件不导入。 +// 文件可以随意改写。元数据的类型不对时当作没有,由服务端决定用不用。音源和 ID 不对就认不出是哪首歌,整个文件都不导入。 function importedTrack(entry: unknown): ImportedTrack | null { if (!isRecord(entry) || typeof entry.source !== 'string' || !sources.has(entry.source) || typeof entry.id !== 'string' || entry.id === '') return null diff --git a/web/src/features/playlists/playlist-menu.tsx b/web/src/features/playlists/playlist-menu.tsx index df65520..61c3259 100644 --- a/web/src/features/playlists/playlist-menu.tsx +++ b/web/src/features/playlists/playlist-menu.tsx @@ -12,7 +12,7 @@ async function copyShareLink(shareId: string, copied: string): Promise { notificationStore.success(copied) } catch { - // 非安全上下文没有剪贴板接口,用户也可能拒绝授权;有的浏览器在请求返回之后已不算用户操作,也会拒绝写入。 + // 非安全上下文没有剪贴板接口,用户也可能拒绝授权。有的浏览器在请求返回之后已经不算用户操作,也会拒绝写入。 notificationStore.info(`请手动复制分享链接:${url}`, 8000) } } diff --git a/web/src/features/playlists/playlist-sort.ts b/web/src/features/playlists/playlist-sort.ts index a2fac58..40529db 100644 --- a/web/src/features/playlists/playlist-sort.ts +++ b/web/src/features/playlists/playlist-sort.ts @@ -35,7 +35,7 @@ function compareIds(a: MyPlaylist, b: MyPlaylist): number { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 } -// 方向作用于整个排序键,反向得到的正好是倒过来的列表;“我喜欢”不参与,始终在最前。 +// 方向作用于整个排序键,所以反向得到的正好是倒过来的列表。“我喜欢”不参与排序,始终在最前。 export function sortPlaylists(list: readonly MyPlaylist[], { key, order }: PlaylistSort): MyPlaylist[] { const sign = order === 'asc' ? 1 : -1 return [...list].sort((a, b) => Number(b.favorites) - Number(a.favorites) diff --git a/web/src/features/playlists/playlist-tracks.tsx b/web/src/features/playlists/playlist-tracks.tsx index dab502a..2e82fbe 100644 --- a/web/src/features/playlists/playlist-tracks.tsx +++ b/web/src/features/playlists/playlist-tracks.tsx @@ -20,7 +20,7 @@ function rowOf(list: HTMLElement | null, key: string): Element | null { return list?.querySelector(`[data-key="${CSS.escape(key)}"]`) ?? null } -// 多选时焦点落在勾选框上,读屏软件才读得出这一行是否选中;平时落在行上。 +// 多选时焦点落在勾选框上,这样读屏软件才读得出这一行是否选中。平时焦点落在行上。 function focusRow(row: Element | null | undefined, selecting: boolean): void { const target = selecting ? row?.querySelector('.playlist-track-check') : row if (target instanceof HTMLElement) @@ -40,7 +40,7 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions, const lineRef = useRef(null) const labelRef = useRef(null) const focusAfterMoveRef = useRef(null) - // 最后获得焦点的行。勾选框随多选出现和消失,焦点在它上面时会丢。 + // 最后获得焦点的行。勾选框随多选出现和消失,焦点在勾选框上时会跟着丢掉。 const focusedRef = useRef(null) const keys = items.map(item => trackKey(item.track)) const { dragging, onPointerDown } = useTrackDrag({ diff --git a/web/src/features/playlists/playlists-page.tsx b/web/src/features/playlists/playlists-page.tsx index 7de20c6..1ba202c 100644 --- a/web/src/features/playlists/playlists-page.tsx +++ b/web/src/features/playlists/playlists-page.tsx @@ -53,7 +53,7 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla const profileFailed = useStore(profileStore, s => s.failed) const [preference, setPreference] = useState(loadViewPreference) const [creating, setCreating] = useState(false) - // 删除歌单之后要获得焦点的歌单(null 表示标题);每次删除换一个新对象,让同一个目标也能再次触发。 + // 删除歌单之后要获得焦点的歌单(null 表示标题)。每次删除都换一个新对象,这样同一个目标也能再次触发。 const [refocus, setRefocus] = useState<{ id: string | null } | null>(null) const titleRef = useRef(null) const now = useStore(playlistsStore, s => s.loadedAt) diff --git a/web/src/features/playlists/playlists-store.test.ts b/web/src/features/playlists/playlists-store.test.ts index d8257d3..43be57f 100644 --- a/web/src/features/playlists/playlists-store.test.ts +++ b/web/src/features/playlists/playlists-store.test.ts @@ -349,7 +349,7 @@ describe('playlistsStore', () => { await flush() void playlistsStore.containing(rain) await flush() - // 先放行加入,同步结束;再放行加入之前发出的读取。 + // 先放行加入,让同步结束。再放行加入之前发出的读取。 release.shift()!() await flush() release.shift()!() diff --git a/web/src/features/playlists/playlists-store.ts b/web/src/features/playlists/playlists-store.ts index 263b2cc..bf637bc 100644 --- a/web/src/features/playlists/playlists-store.ts +++ b/web/src/features/playlists/playlists-store.ts @@ -20,7 +20,7 @@ interface PlaylistsState { playlists: MyPlaylist[] | null // 最近一次读取失败的原因,成功读取后为 null。 error: string | null - // 歌单最近一次读取成功的时间,相对日期(今天、昨天)以它为准:窗口重新获得焦点会重新读取,跨过午夜后标签随之更新。 + // 歌单最近一次读取成功的时间,相对日期(今天、昨天)以这个时间为准。窗口重新获得焦点时会重新读取,所以跨过午夜之后,标签也会随之更新。 loadedAt: number // 曲目 → 包含它的歌单 id,只有读过的曲目才有。正在同步的歌单是最后一次勾选的结果,同步负责把服务端改成一致。 memberships: ReadonlyMap> @@ -30,13 +30,13 @@ const empty: PlaylistsState = { playlists: null, error: null, loadedAt: 0, membe const nameTexts = { INVALID_ARGUMENT: '歌单名要 1 到 50 个字。' } const fileTexts = { INVALID_ARGUMENT: '文件里有不符合要求的内容,没有导入。' } -// 接口只收 1 到 2048 字节的文本:太长的这里不发出,对话框不提交空的,INVALID_ARGUMENT 就只剩认不出链接,这时的 message 是写给用户的。 +// 接口只收 1 到 2048 字节的文本。太长的在这里就不发出,对话框也不提交空的,所以 INVALID_ARGUMENT 只剩认不出链接这一种原因,这时的 message 是写给用户的。 const maxLinkBytes = 2048 const linkTexts: ErrorTexts = { INVALID_ARGUMENT: message => message } class PlaylistsStore extends Store { #account: string | null = null - // 只有最后发起的读取能写入:换了账号,或者之后又读过一次时,旧的结果作废。 + // 只有最后发起的读取能写入。换了账号,或者之后又读过一次时,旧的结果作废。 #generation = 0 #membershipReads = new Map() #syncing = new Map>() @@ -56,7 +56,7 @@ class PlaylistsStore extends Store { return this.#load(++this.#generation) } - // 读取包含这首歌的歌单,写入 memberships;没读到时返回 false。读到了却已被之后的读取取代时也返回 true,由后来的读取写入。 + // 读取包含这首歌的歌单,写入 memberships。没读到时返回 false。读到了却已被之后的读取取代时也返回 true,由后来的读取写入。 async containing(track: Track): Promise { const account = this.#account const key = trackKey(track) @@ -89,7 +89,7 @@ class PlaylistsStore extends Store { if (this.#syncing.get(key) !== sync) return this.#syncing.delete(key) - // 同步之前发出的读取可能带着旧的勾选返回,重新读一次让它们作废;请求超时但服务端已生效时,也以服务端为准。 + // 同步之前发出的读取可能带着旧的勾选返回,所以重新读一次,让这些读取作废。请求超时但服务端已经生效时,也以服务端为准。 void this.containing(track) void this.load() }) @@ -184,7 +184,7 @@ class PlaylistsStore extends Store { } } - // 返回新加入的首数,to 里已经有的歌不算;失败时返回 null。 + // 返回新加入的首数,to 里已经有的歌不算。失败时返回 null。 async copyItems(from: MyPlaylist, to: MyPlaylist, tracks: Track[]): Promise { try { const { added } = await api.copyMyPlaylistItems(to.id, { from: from.id, tracks }) @@ -210,7 +210,7 @@ class PlaylistsStore extends Store { } } - // 开启分享和重新生成链接都用它,返回新的分享 ID;失败时返回 null。 + // 开启分享和重新生成链接都用 share,返回新的分享 ID。失败时返回 null。 async share(playlist: MyPlaylist): Promise { try { const { shareId } = await api.shareMyPlaylist(playlist.id) @@ -247,19 +247,19 @@ class PlaylistsStore extends Store { } } - // 先改列表再重新读取:菜单要立刻显示新的分享状态,重新读取也让进行中的旧读取作废。 + // 先改列表再重新读取,因为菜单要立刻显示新的分享状态。重新读取也会让进行中的旧读取作废。 #changed(id: string, change: Partial): void { this.setState({ playlists: this.state.playlists?.map(p => p.id === id ? { ...p, ...change } : p) ?? null }) void this.load() } - // 先放进列表再重新读取:打开新歌单时,列表里要已经有它,否则详情页会以为它被删掉了。 + // 先放进列表再重新读取,因为打开新歌单时,列表里要已经有这个歌单,否则详情页会以为它被删掉了。 #added(playlist: MyPlaylist): void { this.setState({ playlists: [...(this.state.playlists ?? []), playlist] }) void this.load() } - // 请求进行中又改了勾选时,等它完成之后按最新的勾选再发;失败时恢复成服务端的状态,不再发出。 + // 请求进行中又改了勾选时,等这个请求完成之后,按最新的勾选再发。失败时恢复成服务端的状态,不再发出。 async #sync(playlistId: string, track: Track, saved: boolean): Promise { const account = this.#account while (this.#account === account && this.#includes(playlistId, track) !== saved) { @@ -293,7 +293,7 @@ class PlaylistsStore extends Store { } } - // 正在同步的歌单以本地为准:读到的可能是同步完成之前的状态。 + // 正在同步的歌单以本地为准,因为读到的可能是同步完成之前的状态。 #applyMemberships(track: Track, playlistIds: readonly string[]): void { const syncing = (id: string) => this.#syncing.has(membershipKey(id, track)) const next = new Set(playlistIds.filter(id => !syncing(id))) diff --git a/web/src/features/playlists/playlists.css b/web/src/features/playlists/playlists.css index 65f7773..b7f232f 100644 --- a/web/src/features/playlists/playlists.css +++ b/web/src/features/playlists/playlists.css @@ -21,7 +21,7 @@ .playlist-cover-sm { width: 40px; } .playlist-cover-md { width: 100%; } .playlist-cover-lg { width: 232px; border-radius: 10px; } -/* z-index 为负:光晕画在最近的层叠上下文的最底层,在文字之下。放大封面的容器要用 isolation 建立层叠上下文,否则光晕会落到页面背景下面。 */ +/* z-index 为负,所以光晕画在最近的层叠上下文的最底层,在文字之下。放大封面的容器要用 isolation 建立层叠上下文,否则光晕会落到页面背景下面。 */ .playlist-cover-glow { position: absolute; inset: 0; z-index: -1; opacity: var(--glow-opacity); filter: blur(24px) saturate(1.8); transform: translateY(10%) scale(.95); transition: opacity 240ms ease, transform 240ms ease; pointer-events: none; } .playlist-cover-lg > .playlist-cover-glow { filter: blur(32px) saturate(1.8); } :root:is([data-performance='low'], [data-performance='off']) .playlist-cover-glow { display: none; } diff --git a/web/src/features/playlists/save-to-playlist.tsx b/web/src/features/playlists/save-to-playlist.tsx index 31931c4..b9f12db 100644 --- a/web/src/features/playlists/save-to-playlist.tsx +++ b/web/src/features/playlists/save-to-playlist.tsx @@ -16,7 +16,7 @@ import './playlists.css' const byUpdate: PlaylistSort = { key: 'updatedAt', order: 'desc' } const none: ReadonlySet = new Set() -// 保持打开时的先后:保存会更新歌单的更新时间,按实时的时间排,刚勾选的歌单会跳走。打开之后才出现的歌单排在最后。 +// 保持打开对话框时的先后。保存会更新歌单的更新时间,如果按实时的更新时间排,刚勾选的歌单会跳走。打开之后才出现的歌单排在最后。 function arrange(playlists: readonly MyPlaylist[], order: readonly string[]): MyPlaylist[] { const rank = new Map(order.map((id, index) => [id, index])) const at = (playlist: MyPlaylist) => rank.get(playlist.id) ?? order.length @@ -46,7 +46,7 @@ export function SaveToPlaylistDialog(props: SaveToPlaylistProps) { const [creating, setCreating] = useState(false) const [saving, setSaving] = useState(false) const listRef = useRef(null) - // 从新建页回到列表时要获得焦点的元素:取消时是“新建歌单”,建好之后是新歌单的勾选框,聚焦的同时把它滚进视野。 + // 从新建页回到列表时要获得焦点的元素:取消时是“新建歌单”,建好之后是新歌单的勾选框。聚焦的同时会把这个元素滚进视野。 const returnFocusRef = useRef(null) // 打开时刷新歌单:歌曲数和其他设备上新建的歌单。 useEffect(() => { @@ -188,7 +188,7 @@ export function SaveToPlaylistDialog(props: SaveToPlaylistProps) { } export function SaveToPlaylistButton({ track, title, className = '' }: { track: Track, title: string, className?: string }) { - // 记下打开时的歌:对话框开着时换了歌,勾选仍然作用于打开时的那首。 + // 记下打开时的歌。这样对话框开着时换了歌,勾选仍然作用于打开时的那首。 const [target, setTarget] = useState<{ track: Track, title: string } | null>(null) return ( <> diff --git a/web/src/features/playlists/shared-playlist-page.tsx b/web/src/features/playlists/shared-playlist-page.tsx index 39f0957..f98fe81 100644 --- a/web/src/features/playlists/shared-playlist-page.tsx +++ b/web/src/features/playlists/shared-playlist-page.tsx @@ -16,7 +16,7 @@ import { saveToPlaylistLabel } from './save-label' import { SaveToPlaylistDialog } from './save-to-playlist' import './playlists.css' -// 带上读取的分享 ID:换到另一个分享时,上一个的结果不再显示。 +// 结果带上读取时的分享 ID,这样换到另一个分享时,上一个分享的结果不再显示。 type Result = { shareId: string } & ({ kind: 'loaded', playlist: SharedPlaylist } | { kind: 'gone' } | { kind: 'failed', message: string }) function songItem({ track, meta }: MyPlaylistItem, playing: Track | undefined): SongItem { diff --git a/web/src/features/playlists/track-edits.ts b/web/src/features/playlists/track-edits.ts index 8eafd43..64047c1 100644 --- a/web/src/features/playlists/track-edits.ts +++ b/web/src/features/playlists/track-edits.ts @@ -1,6 +1,6 @@ -// 详情页对曲目的修改(移动、移出)先在本地改好再请求。请求按发起的先后逐个发出,每次都基于上一次修改之后的顺序; -// 一次失败之后,排在它后面的不再发出,由失败的那次回到它之前的样子。 -// 修改进行中读到的列表可能是修改之前的,不能用;最后一个修改结束之后再读一次,以服务端为准。 +// 详情页对曲目的修改(移动、移出)先在本地改好再请求。请求按发起的先后逐个发出,每次都基于上一次修改之后的顺序。 +// 一次失败之后,排在后面的修改都不再发出,由失败的那次修改负责回滚,列表回到这次修改之前的样子。 +// 修改进行中读到的列表可能是修改之前的,不能用。最后一个修改结束之后再读一次,以服务端为准。 export class TrackEdits { readonly #reread: () => void #tail: Promise = Promise.resolve() diff --git a/web/src/features/playlists/track-order.ts b/web/src/features/playlists/track-order.ts index 65b600b..11ea099 100644 --- a/web/src/features/playlists/track-order.ts +++ b/web/src/features/playlists/track-order.ts @@ -1,5 +1,5 @@ -// 与后端的 playlist.Move 一致:order 是显示顺序,把 moved 按给出的先后放到 before 之前,没有 before 时放到最后。 -// moved 里不在 order 中的忽略,重复的只算一次;都不在 order 中时原样返回,此时不检查 before 是否还在。 +// 与后端的 playlist.Move 一致。order 是显示顺序,把 moved 按给出的先后放到 before 之前,没有 before 时放到最后。 +// moved 里不在 order 中的忽略,重复的只算一次。moved 都不在 order 中时原样返回,这时不检查 before 是否还在。 export function moveTracks(order: readonly T[], moved: readonly T[], before?: T): T[] { if (before !== undefined && moved.includes(before)) throw new Error('before 不能是要移动的歌') @@ -22,7 +22,7 @@ export interface MovePlan { before: T | undefined } -// 把 moved 收拢成一段,放在其余的歌中第 at 首之前;顺序不变时返回 null,不必发请求。 +// 把 moved 收拢成一段,放在其余的歌中第 at 首之前。顺序不变时返回 null,不必发请求。 function place(order: readonly T[], moved: ReadonlySet, at: number): MovePlan | null { const block = order.filter(item => moved.has(item)) const before = order.filter(item => !moved.has(item))[at] diff --git a/web/src/features/playlists/track-selection.ts b/web/src/features/playlists/track-selection.ts index 4b79f7d..b1689e4 100644 --- a/web/src/features/playlists/track-selection.ts +++ b/web/src/features/playlists/track-selection.ts @@ -13,7 +13,7 @@ export function toggle(selection: Selection, key: string): Selection { return { keys, anchor: key } } -// 把锚点到 key 的一段加入选择;没有锚点,或者锚点已经不在列表里时,只切换 key。 +// 把锚点到 key 的一段加入选择。没有锚点,或者锚点已经不在列表里时,只切换 key。 export function selectRange(selection: Selection, order: readonly string[], key: string): Selection { const from = selection.anchor === null ? -1 : order.indexOf(selection.anchor) const to = order.indexOf(key) @@ -27,7 +27,7 @@ export function selectAll(order: readonly string[]): Selection { return { keys: new Set(order), anchor: null } } -// 列表重新读取之后去掉已经不在里面的歌;没有变化时返回原对象。 +// 列表重新读取之后,从选择里去掉已经不在列表里的歌。没有变化时返回原对象。 export function retain(selection: Selection, order: readonly string[]): Selection { const present = new Set(order) const keys = [...selection.keys].filter(key => present.has(key)) diff --git a/web/src/features/playlists/use-track-drag.ts b/web/src/features/playlists/use-track-drag.ts index dea6290..3ee9d71 100644 --- a/web/src/features/playlists/use-track-drag.ts +++ b/web/src/features/playlists/use-track-drag.ts @@ -32,7 +32,7 @@ function insertionIndex(rows: readonly HTMLElement[], y: number): number { return low } -// 拖动结束时,按下和松开的位置不在同一行,浏览器仍会派发 click:吞掉它,免得切换选中。 +// 拖动结束时,按下和松开的位置不在同一行,浏览器仍会派发 click。所以要吞掉这个 click,免得切换选中。 function swallowNextClick(): void { const swallow = (event: MouseEvent) => { event.stopPropagation() diff --git a/web/src/features/rooms/create-room-dialog.tsx b/web/src/features/rooms/create-room-dialog.tsx index cca2763..8f666cc 100644 --- a/web/src/features/rooms/create-room-dialog.tsx +++ b/web/src/features/rooms/create-room-dialog.tsx @@ -29,7 +29,7 @@ export function CreateRoomDialog({ onCreated, onClose }: { onCreated: (id: strin saveTicket(id, (await api.createTicket(id, secret)).ticket) } catch (error) { - // 房间已经创建:进入后由加入弹窗重新换取票据,留在此处重试会再创建一个房间。 + // 房间已经创建,进入后由加入弹窗重新换取票据。留在此处重试的话,会再创建一个房间。 notificationStore.error(joinErrorMessage(error)) } void roomEntryStore.enter(id) diff --git a/web/src/features/rooms/entry-dialog.tsx b/web/src/features/rooms/entry-dialog.tsx index 257e832..4c17dc9 100644 --- a/web/src/features/rooms/entry-dialog.tsx +++ b/web/src/features/rooms/entry-dialog.tsx @@ -12,7 +12,7 @@ export function EntryDialog({ onCancel }: { onCancel: () => void }) { room={room} onJoined={() => roomEntryStore.retry()} onClose={() => { - // onCancel 要在放弃进入之前:离开这个房间的地址时,要靠进房的信息认出它。 + // onCancel 要在放弃进入之前调用,因为离开时要靠进房的信息认出这个房间的地址。 onCancel() roomEntryStore.reset() }} diff --git a/web/src/features/rooms/room-entry.ts b/web/src/features/rooms/room-entry.ts index c00f839..0d96dda 100644 --- a/web/src/features/rooms/room-entry.ts +++ b/web/src/features/rooms/room-entry.ts @@ -88,7 +88,7 @@ class RoomEntryStore extends Store { return switch (session.stopCode) { case CloseCode.unauthorized: - // 票据无效,或房间启用、更改了密码:重新查询房间,再让用户输入密码。 + // 票据无效,或者房间启用、更改了密码。这时重新查询房间,再让用户输入密码。 deleteTicket(room.id) this.retry() return diff --git a/web/src/features/search/link.ts b/web/src/features/search/link.ts index 8343e40..5024825 100644 --- a/web/src/features/search/link.ts +++ b/web/src/features/search/link.ts @@ -6,7 +6,7 @@ export function linkOf(input: string): string | null { return isLink(link) ? link : null } -// 临时房间只能点 B 站的链接:服务端把它们识别成 B 站音源的歌,其他链接是只能在常驻房间点的链接曲目。kind 为 undefined 时不在房间里。 +// 临时房间只能点 B 站的链接,因为服务端把 B 站链接识别成 B 站音源的歌,其他链接则是只能在常驻房间点的链接曲目。kind 为 undefined 时不在房间里。 export function pickableLink(input: string, kind: RoomKind | undefined): string | null { const link = linkOf(input) if (link === null || kind === undefined) diff --git a/web/src/features/search/search-box.tsx b/web/src/features/search/search-box.tsx index 1d9c2b6..5315573 100644 --- a/web/src/features/search/search-box.tsx +++ b/web/src/features/search/search-box.tsx @@ -87,7 +87,7 @@ export function SearchBox({ className, recent, onSearch }: { className: string, onClick={() => { searchStore.setText('') onSearch(searchStore.withKeyword('')) - // 清空按钮随文字一起卸载,需要把焦点移回输入框。 + // 清空按钮随文字一起卸载,所以要把焦点移回输入框。 inputRef.current?.focus() }} /> @@ -105,7 +105,7 @@ export function SearchBox({ className, recent, onSearch }: { className: string, setLinking(true) const added = await enqueue({ source: 'link', id: link }) setLinking(false) - // 请求期间输入框仍可编辑:文字未变时才清空,期间输入的内容要保留。 + // 请求期间输入框仍可编辑,所以文字没变时才清空,期间输入的内容要保留。 if (added && searchStore.state.text === text) { searchStore.setText('') setOpen(false) diff --git a/web/src/features/search/search-store.ts b/web/src/features/search/search-store.ts index c6ddf0a..a139718 100644 --- a/web/src/features/search/search-store.ts +++ b/web/src/features/search/search-store.ts @@ -45,7 +45,7 @@ interface Results { interface SearchState extends Results { text: string - // 当前结果对应的关键词;为空时页面显示最近搜索。 + // 当前结果对应的关键词,为空时页面显示最近搜索。 keyword: string source: SearchSource view: SearchView @@ -67,7 +67,7 @@ export function keywordSearch(keyword: string, source: SearchSource, category: S return { view: category === 'playlists' && !canOpenPlaylists(source) ? 'all' : category, keyword, source, page } } -// 用户发起的搜索记入搜索历史:提交、点最近搜索、换来源和类别;翻页、推荐、打开歌单,以及前进、后退还原的搜索都不记。 +// 用户发起的搜索记入搜索历史,包括提交、点最近搜索、换来源和类别。翻页、推荐、打开歌单,以及前进、后退还原的搜索都不记。 export function recordSearch(query: KeywordQuery): void { if (query.keyword) historyStore.recordSearch(query.keyword, query.source, query.view) @@ -79,7 +79,7 @@ function sameQuery(a: SearchQuery, b: SearchQuery): boolean { return a.view === b.view && a.keyword === b.keyword && a.source === b.source && a.page === b.page } -// shown 是当前结果对应的条件。缓存里有就直接用,不必连着房间;连上房间之前不发出请求:推迟时让当前结果作废,之后回到哪个条件都会重新取。 +// shown 是当前结果对应的条件。缓存里有就直接用,不必连着房间。连上房间之前不发出请求,推迟时让当前结果作废,这样之后回到哪个条件都会重新取。 export function nextLoad(key: string | null, shown: string | null, cached: boolean, connected: boolean, force: boolean): { shown: string | null, loading: boolean, from: 'none' | 'cache' | 'request' } | null { if (key === null) return { shown: null, loading: false, from: 'none' } @@ -104,13 +104,13 @@ export function rememberResult(cache: ReadonlyMap>, key: st return new Map([...kept, [key, entry] as [string, Cached]].slice(-cacheLimit)) } -// 显示什么由地址决定:页面只把新的条件交给导航,地址变化之后再由 show() 和 showPlaylist() 取结果。 +// 显示什么由地址决定。页面只把新的条件交给导航,地址变化之后再由 show() 和 showPlaylist() 取结果。 class SearchStore extends Store { #request = 0 #roomId: string | null = null #shown: string | null = null #cache: ReadonlyMap>> = new Map() - // 只有显示着搜索页或歌单页时才请求,连上房间时也只为它们补发。 + // 只有显示着搜索页或歌单页时才请求,连上房间时也只为这两种页面补发。 #visible = false constructor() { @@ -137,7 +137,7 @@ class SearchStore extends Store { } show(query: SearchQuery): void { - // 地址里的条件变了,搜索框的文字就跟着地址里的关键词,没有关键词时清空;只是开关侧栏、关掉弹窗时不动正在输入的文字。 + // 地址里的条件变了,搜索框的文字就跟着地址里的关键词,没有关键词时清空。只是开关侧栏、关掉弹窗时,不动正在输入的文字。 const changed = !this.#visible || this.state.playlist !== null || !sameQuery(this.query, query) this.#visible = true const keyword = query.view === 'recommend' ? '' : query.keyword @@ -227,7 +227,7 @@ class SearchStore extends Store { return [view === 'tracks' ? tracks(page) : playlists(page)] } - // 结果和缓存只属于当前房间,换了房间都作废(推荐由房间的播放历史得出);连上房间时补发还没有发出的请求。 + // 结果和缓存只属于当前房间(推荐由房间的播放历史得出),换了房间都作废。连上房间时补发还没有发出的请求。 #onRoom = (): void => { const { roomId, status } = roomStore.state if (roomId !== this.#roomId) { diff --git a/web/src/features/search/search.css b/web/src/features/search/search.css index 2b3a8fc..cfffd11 100644 --- a/web/src/features/search/search.css +++ b/web/src/features/search/search.css @@ -113,7 +113,7 @@ } @media (max-width: 767px) { .search-page { padding-top: 8px; } - /* 窄屏的按钮宽 28px,搜索容器不超过 480px 时宽 32px:最后一列取按钮的宽度,按钮才能紧贴行的右侧。 */ + /* 窄屏的按钮宽 28px,搜索容器不超过 480px 时宽 32px。最后一列取按钮的宽度,按钮才能紧贴行的右侧。 */ .search-song-table { --search-song-columns: 42px minmax(0, 1fr) auto; } .search-song-table .song-album, .search-song-heading { display: none; } .search-preview .song-row { grid-template-columns: 42px minmax(0, 1fr) auto; } diff --git a/web/src/features/settings/account-dialogs.tsx b/web/src/features/settings/account-dialogs.tsx index 1df678f..a9e214b 100644 --- a/web/src/features/settings/account-dialogs.tsx +++ b/web/src/features/settings/account-dialogs.tsx @@ -13,7 +13,7 @@ function submitted(event: FormEvent, run: () => Promise): void { void run() } -// 绑定和解绑 GitHub 都会更换登录方式,先输入当前密码:只凭这台设备上的登录不能更换。onConfirm 返回 false 时,失败原因已经通过通知显示; +// 绑定和解绑 GitHub 都会更换登录方式,要先输入当前密码,因为只凭这台设备上的登录不能更换。onConfirm 返回 false 时,失败原因已经通过通知显示。 // 绑定成功时页面随即跳到 GitHub,对话框保持忙碌。 export function CurrentPasswordDialog({ title, action, onConfirm, onClose }: { title: string, action: string, onConfirm: (currentPassword: string) => Promise, onClose: () => void }) { const [currentPassword, setCurrentPassword] = useState('') @@ -57,7 +57,7 @@ export function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login } } catch (error) { - // 用 GitHub 确认身份已经过期:在对话框里重试也没用,关掉它,从按钮重新确认。 + // 用 GitHub 确认身份已经过期,在对话框里重试也没用,所以关掉对话框,从按钮重新确认。 if (mode === 'credentials' && error instanceof ApiError && error.code === 'FORBIDDEN') { notificationStore.error('用 GitHub 确认身份已经超过 10 分钟,请在“用户名和密码”一行再点一次“设置”。') onClose() diff --git a/web/src/features/settings/appearance-store.ts b/web/src/features/settings/appearance-store.ts index 56ad8e6..f82ac36 100644 --- a/web/src/features/settings/appearance-store.ts +++ b/web/src/features/settings/appearance-store.ts @@ -41,7 +41,7 @@ class AppearanceStore extends Store<{ theme: Theme, fontSize: number }> { #apply(): void { const root = document.documentElement root.dataset.theme = this.state.theme - // 只改 --ui-font-size,不改根元素的字号:间距和布局尺寸不随字号缩放。 + // 只改 --ui-font-size,不改根元素的字号,这样间距和布局尺寸不随字号缩放。 root.style.setProperty('--ui-font-size', `${this.state.fontSize}px`) this.#syncThemeColor() } diff --git a/web/src/features/settings/github-login-page.tsx b/web/src/features/settings/github-login-page.tsx index 1ee3e25..5dc6d51 100644 --- a/web/src/features/settings/github-login-page.tsx +++ b/web/src/features/settings/github-login-page.tsx @@ -28,7 +28,7 @@ export function GitHubLoginPage({ fragment, onDone }: { fragment: string, onDone done() return } - // 注册或绑定:成员和成员 token 都不变,不必重新加载。能绑定 GitHub 的只有设置了用户名的账号,注册得到的账号没有用户名。 + // 注册或绑定时,成员和成员 token 都不变,不必重新加载。能绑定 GitHub 的只有设置了用户名的账号,注册得到的账号没有用户名。 void profileStore.registered(result.account) notificationStore.success(result.account.username ? '已绑定 GitHub' : '已用 GitHub 注册') done() diff --git a/web/src/features/settings/github.ts b/web/src/features/settings/github.ts index 0901000..70b6e6c 100644 --- a/web/src/features/settings/github.ts +++ b/web/src/features/settings/github.ts @@ -4,8 +4,8 @@ import { api, IdentityRenewedError } from '@/core/api' import { errorMessage } from '@/core/errors' import { notificationStore } from '@/shared/ui' -// GitHub 跳回来时仍在发起授权的这个标签页,所以 nonce 存在 sessionStorage:别的标签页、别的浏览器拿到兑换码也没有它。 -// 意图也存在这里,回来之后据此决定接下来做什么。 +// GitHub 跳回来时仍在发起授权的这个标签页,所以 nonce 存在 sessionStorage。这样别的标签页、别的浏览器拿到兑换码,也拿不到 nonce。 +// 意图也存在 sessionStorage 里,回来之后据此决定接下来做什么。 const nonceKey = 'alisten.github-nonce' const intentKey = 'alisten.github-intent' @@ -28,7 +28,7 @@ export async function startGithubLogin(intent: GitHubIntent, currentPassword?: s return url } -// 返回 false 时,失败原因已经通过通知显示;成功时页面随即跳到 GitHub。 +// 返回 false 时,失败原因已经通过通知显示。成功时页面随即跳到 GitHub。 export async function goToGitHub(intent: GitHubIntent, currentPassword?: string): Promise { try { window.location.assign(await startGithubLogin(intent, currentPassword)) @@ -46,7 +46,7 @@ export type GithubLanding let landed: { fragment: string, landing: GithubLanding } | null = null -// fragment 是落地页地址中 # 之后的部分:code=<兑换码> 或 error=<错误码>。保存的 nonce 取出即删除,每次发起只能兑换一次; +// fragment 是落地页地址中 # 之后的部分:code=<兑换码> 或 error=<错误码>。保存的 nonce 取出即删除,所以每次发起只能兑换一次。 // 同一次落地重复读取时(开发模式下组件会挂载两次)返回同样的结果。 export function takeGithubLanding(fragment: string): GithubLanding { if (landed?.fragment === fragment) @@ -72,7 +72,7 @@ export function takeGithubLanding(fragment: string): GithubLanding { let finishing: { code: string, result: Promise } | null = null let credentialsPending = false -// 兑换码只能用一次:同一个兑换码重复调用时返回同一次请求的结果。 +// 兑换码只能用一次,所以同一个兑换码重复调用时,返回同一次请求的结果。 export function finishGithubLogin(code: string, nonce: string, intent: GitHubIntent): Promise { if (finishing?.code !== code) { let result = api.finishGithubLogin({ code, nonce }) @@ -87,7 +87,7 @@ export function finishGithubLogin(code: string, nonce: string, intent: GitHubInt return finishing.result } -// 用 GitHub 确认了身份,接下来设置用户名和密码:落地页回到设置页,设置页打开对话框。用户名和密码不在跳转期间保存,回来之后才填写。 +// 用 GitHub 确认了身份之后,接下来设置用户名和密码。落地页回到设置页,由设置页打开对话框。用户名和密码不在跳转期间保存,回来之后才填写。 // 只返回一次 true。 export function takeCredentialsPrompt(): boolean { const pending = credentialsPending diff --git a/web/src/features/settings/notification-permission.ts b/web/src/features/settings/notification-permission.ts index 7967259..aeed80c 100644 --- a/web/src/features/settings/notification-permission.ts +++ b/web/src/features/settings/notification-permission.ts @@ -8,8 +8,8 @@ interface NotificationPermissionState { refused: boolean } -// 浏览器的授权提示可能一直不返回:用户没理会,或者浏览器把提示收进了地址栏。所以等待期间可以放弃; -// 放弃之后提示才返回时只更新权限,不算这次申请的结果。 +// 浏览器的授权提示可能一直不返回,比如用户没理会,或者浏览器把提示收进了地址栏。所以等待期间可以放弃。 +// 放弃之后提示才返回时,只更新权限,不算这次申请的结果。 export class NotificationPermissionRequest extends Store { #latest = 0 @@ -17,8 +17,8 @@ export class NotificationPermissionRequest extends Store { const id = ++this.#latest this.setState({ waiting: true, refused: false }) diff --git a/web/src/features/settings/pair-page.tsx b/web/src/features/settings/pair-page.tsx index 0372a4c..6707998 100644 --- a/web/src/features/settings/pair-page.tsx +++ b/web/src/features/settings/pair-page.tsx @@ -3,7 +3,7 @@ import { PairingCodeForm } from './pairing-dialog' import { profileStore, switchIdentity } from './profile-store' import './settings.css' -// 扫描配对二维码打开的页面。要用户点一下才兑换:打开链接就自动登录的话,别人发来的链接能把你登进他的账号。 +// 扫描配对二维码打开的页面。要用户点一下才兑换,因为打开链接就自动登录的话,别人发来的链接能把你登进他的账号。 // onSignedIn 在换身份、重新加载页面之前调用,由应用壳决定登录之后停在哪里。 export function PairPage({ code, onSignedIn }: { code: string, onSignedIn: () => void }) { const account = useStore(profileStore, s => s.account) diff --git a/web/src/features/settings/pairing-dialog.tsx b/web/src/features/settings/pairing-dialog.tsx index 242ae13..2db5d42 100644 --- a/web/src/features/settings/pairing-dialog.tsx +++ b/web/src/features/settings/pairing-dialog.tsx @@ -58,7 +58,7 @@ export function PairingDialog({ onClose }: { onClose: () => void }) { ) } -// 兑换配对码就是登录:成功时把新会话的成员 token 交给调用方,由它换身份。 +// 兑换配对码就是登录。成功时把新会话的成员 token 交给调用方,由调用方换身份。 export function PairingCodeForm({ initialCode = '', onSignedIn }: { initialCode?: string, onSignedIn: (memberToken: string) => void }) { const [input, setInput] = useState(() => normalizePairingCode(initialCode)) const [busy, setBusy] = useState(false) diff --git a/web/src/features/settings/password-login.ts b/web/src/features/settings/password-login.ts index a242553..d55dc85 100644 --- a/web/src/features/settings/password-login.ts +++ b/web/src/features/settings/password-login.ts @@ -1,9 +1,9 @@ import { api } from '@/core/api' -// 按用户名保存服务端发给本机的登录设备凭证:一台设备可能登录过多个账号。退出登录时保留,下次登录这个账号时带上, -// 别人反复猜这个账号的密码时本机仍然能登录。 +// 按用户名保存服务端发给本机的登录设备凭证,因为一台设备可能登录过多个账号。退出登录时保留凭证,下次登录这个账号时带上, +// 这样别人反复猜这个账号的密码时,本机仍然能登录。 const devicesKey = 'alisten.login-devices' -// 与协议中的 LoginDevice 一致:本机保存的内容被改坏时不能带上,否则服务端会拒绝整个登录请求。 +// 与协议中的 LoginDevice 一致。本机保存的内容被改坏时不能带上,否则服务端会拒绝整个登录请求。 const devicePattern = /^[\w-]{43}$/ function loadDevices(): Record { diff --git a/web/src/features/settings/profile-store.ts b/web/src/features/settings/profile-store.ts index aa93b41..cde681b 100644 --- a/web/src/features/settings/profile-store.ts +++ b/web/src/features/settings/profile-store.ts @@ -5,14 +5,14 @@ import { errorMessage } from '@/core/errors' import { Store } from '@/shared/lib/store' import { notificationStore } from '@/shared/ui' -// 游客的邮箱只保存在本机,服务端只保存它的哈希;已注册账号的邮箱保存在账号里。 +// 游客的邮箱只保存在本机,服务端只保存邮箱的哈希。已注册账号的邮箱保存在账号里。 const emailKey = 'alisten.email' interface ProfileState { member: Member | null account: Account | null email: string - // 是否记录播放历史,保存在服务端;还没有读到时为 null。 + // 是否记录播放历史。这个设置保存在服务端,还没有读到时为 null。 recordListens: boolean | null failed: boolean } @@ -38,7 +38,7 @@ class ProfileStore extends Store { return this.#loading } - // 本机已有的邮箱存进新账号,存失败只提示,不影响注册;本机的副本保留,下次加载时重试。 + // 本机已有的邮箱存进新账号。存失败时只提示,不影响注册。这时本机的副本保留,下次加载时重试。 async registered(account: Account): Promise { this.setState({ account }) await this.#moveLocalEmail() @@ -67,7 +67,7 @@ class ProfileStore extends Store { return true } - // 一次只改一项,失败时才能说清是哪一项不合格:客户端不能按服务端的 message 区分字段。失败时抛出 Error,message 是给用户看的原因。 + // 一次只改一项,失败时才能说清是哪一项不合格,因为客户端不能按服务端的 message 区分字段。失败时抛出 Error,message 是给用户看的原因。 async save(changes: { name: string } | { email: string }): Promise { const error = await this.#update(changes) if (error === null) @@ -140,7 +140,7 @@ class ProfileStore extends Store { if (error === null) return notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' })) - // 服务端不接受的副本重试也不会成功,删掉免得每次加载都报错;其他错误(网络、服务端故障)留到下次再试。 + // 服务端不接受的副本,重试也不会成功,所以删掉,免得每次加载都报错。其他错误(网络、服务端故障)留到下次再试。 if (error instanceof ApiError && error.code === 'INVALID_ARGUMENT') localStorage.removeItem(emailKey) } @@ -166,7 +166,7 @@ class ProfileStore extends Store { export const profileStore = new ProfileStore() -// 换了身份之后,进房票据和本机保存的邮箱都属于原来的身份,一并清除;重新加载页面,让房间连接和各处的状态都从新身份开始。 +// 换了身份之后,进房票据和本机保存的邮箱都属于原来的身份,所以一并清除。然后重新加载页面,让房间连接和各处的状态都从新身份开始。 export function switchIdentity(token: string | null): void { if (token === null) deleteMemberToken() diff --git a/web/src/features/settings/setting-row.tsx b/web/src/features/settings/setting-row.tsx index e8da09b..24fff3f 100644 --- a/web/src/features/settings/setting-row.tsx +++ b/web/src/features/settings/setting-row.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { useSettingSave } from '@/shared/lib/use-setting-save' import { SaveStatus, Switch } from '@/shared/ui' -// id 是行内控件的 id:标题用 label 指向它,说明和错误的 id 由它派生。 +// id 是行内控件的 id。标题用 label 指向这个控件,说明和错误的 id 都由 id 派生。 export function SettingRow({ id, title, description, error, status, labelled = true, children }: { id: string title: ReactNode diff --git a/web/src/features/settings/settings-account.tsx b/web/src/features/settings/settings-account.tsx index bce3db0..51c0943 100644 --- a/web/src/features/settings/settings-account.tsx +++ b/web/src/features/settings/settings-account.tsx @@ -44,10 +44,10 @@ export function IdentityEntry({ onOpen }: { onOpen: () => void }) { ) } -// 编辑时才挂载:每次开始编辑都从当前的昵称开始,保存中和失败的状态随编辑结束一起丢掉。 +// 编辑时才挂载,这样每次开始编辑都从当前的昵称开始,保存中和失败的状态也随编辑结束一起丢掉。 function NameInput({ name, onDone }: { name: string, onDone: (saved: boolean, refocus: boolean) => void }) { const inputRef = useRef(null) - // 结束编辑之后输入框随即卸载,有的浏览器会在卸载时触发失焦,不能再提交一次。 + // 结束编辑之后输入框随即卸载。有的浏览器会在卸载时触发失焦,这时不能再提交一次。 const doneRef = useRef(false) const errorId = useId() const { state, edit, submit } = useInlineSave(name, value => profileStore.save({ name: value.trim() })) @@ -140,7 +140,7 @@ function NameEditor({ name }: { name: string }) { } return (
- {/* 点名字也能编辑;键盘用旁边的铅笔按钮。 */} + {/* 点名字也能编辑。键盘操作时用旁边的铅笔按钮。 */} setEditing(true)}>{name}