Compare commits

..
1 Commits
Author SHA1 Message Date
DiheChen 535136bc7b ci: check readiness and the release version after deploy
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
The ops port serves GET /version, so the smoke test can confirm that the
upgraded build is the one answering.
2026-11-07 08:07:21 +08:00
483 changed files with 2284 additions and 33969 deletions

No files matched your search

+20 -28
View File
@@ -1,10 +1,9 @@
name: CD
on:
workflow_call:
secrets:
K3S_KUBECONFIG:
required: true
workflow_run:
workflows: [CI]
types: [completed]
concurrency:
group: cd-alisten
@@ -16,9 +15,13 @@ permissions:
jobs:
deploy:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
env:
TAG: ${{ github.ref_name }}
TAG: ${{ github.event.workflow_run.head_branch }}
steps:
- uses: azure/setup-helm@v5
with:
@@ -56,33 +59,22 @@ jobs:
shell: bash
env:
KUBECONFIG: ${{ runner.temp }}/k3s-kubeconfig
HEAD_SHA: ${{ github.sha }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
kubectl port-forward \
--namespace alisten \
--address 127.0.0.1 \
service/alisten-serve 18090:9090 &
kubectl port-forward --namespace alisten service/alisten-serve 18090:9090 &
pid=$!
trap 'kill "$pid" 2>/dev/null || true' EXIT
base_url="http://127.0.0.1:18090"
curl --silent --show-error --fail \
--connect-timeout 2 \
--max-time 5 \
--retry 20 \
--retry-delay 1 \
--retry 10 \
--retry-delay 2 \
--retry-connrefused \
--retry-max-time 60 \
"${base_url}/readyz"
--max-time 10 \
http://127.0.0.1:18090/readyz
# CI 构建发布镜像时把 tag+commit 写进版本号。
expected="${TAG}+${HEAD_SHA}"
actual="$(curl --silent --show-error --fail \
--connect-timeout 2 \
--max-time 5 \
"${base_url}/version")"
diff -u --label expected --label actual \
<(echo "$expected") <(echo "$actual")
actual="$(curl --silent --show-error --fail --max-time 10 http://127.0.0.1:18090/version)"
if [[ "$actual" != "$expected" ]]; then
printf '线上版本是 %s,期望 %s\n' "$actual" "$expected" >&2
exit 1
fi
-10
View File
@@ -162,13 +162,3 @@ jobs:
repository="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
helm push "${RUNNER_TEMP}/helm-packages/alisten-${chart_version}.tgz" "oci://ghcr.io/${repository}/charts"
deploy:
needs: helm-release
if: github.ref_type == 'tag'
uses: ./.github/workflows/cd.yml
secrets:
K3S_KUBECONFIG: ${{ secrets.K3S_KUBECONFIG }}
permissions:
contents: read
packages: read
+7 -7
View File
@@ -7,7 +7,7 @@ linters:
- forbidigo
exclusions:
rules:
# 这两个文件里未检查的错误不影响结果。decode.go 跳过 fmt 块的对齐填充失败时,读下一个块头会报错。loudness_bench_test.go 的 binary.Write 写入的是 bytes.Buffer,不会失败。
# 这两个文件中未检查的错误不影响结果:decode.go 跳过 fmt 块的对齐填充失败时,读取下一个块头会报错;loudness_bench_test.go 的 binary.Write 写入 bytes.Buffer,不会失败。
- path: internal/lib/wav/decode\.go|internal/lib/loudness/loudness_bench_test\.go
linters:
- errcheck
@@ -17,8 +17,8 @@ linters:
- forbidigo
settings:
depguard:
# 不以 $ 结尾的条目按前缀匹配。depguard 只拿排序后紧挨在导入路径之前的那一项比较,所以已经有前缀的子包不要再单列。
# 比如 deny 里同时有 os 和 os/exec 时,os/signal 就不再被禁止。
# 不以 $ 结尾的条目按前缀匹配。depguard 只拿排序后紧挨在导入路径之前的一项比较,已有前缀的子包不要再单列:
# deny 中同时有 os 和 os/exec 时,os/signal 就不再被禁止。
rules:
domain:
list-mode: strict
@@ -77,9 +77,9 @@ linters:
- github.com/dihemoe/alisten/internal/domain
- github.com/dihemoe/alisten/internal/lib
- github.com/dihemoe/alisten/internal/app
# 用于房间密码和账号密码的 argon2id 哈希。argon2 是纯计算,不做 I/O。
# 用于房间密码的 argon2id 哈希;纯计算,不做 I/O。
- golang.org/x/crypto/argon2
# 用于合并同一首歌的并发播放地址解析。singleflight 只协调 goroutine,不做 I/O。
# 用于合并同一首歌的并发播放地址解析;只协调 goroutine,不做 I/O。
- golang.org/x/sync/singleflight
deny:
- pkg: os
@@ -138,7 +138,7 @@ linters:
desc: lib 与业务无关,不能依赖 bootstrap
- pkg: github.com/dihemoe/alisten/protocol
desc: protocol 只能被 internal/adapter/in 使用
# app 通过 logctx 记录日志的关联字段。logctx 只依赖标准库,这样 app 才不会经由 logctx 依赖 OpenTelemetry。
# app 通过 logctx 记录日志的关联字段;logctx 只依赖标准库,app 才不会经由它依赖 OpenTelemetry。
logctx:
list-mode: strict
files:
@@ -157,7 +157,7 @@ linters:
list-mode: lax
files:
- "**/internal/adapter/out/**"
# sqlc 把查询代码生成在 postgres 适配器的 internal 目录下。用 lax 模式,是为了让这个更长的 allow 前缀优先于 deny。
# sqlc 把查询代码生成在 postgres 适配器的 internal 目录下;lax 模式让这个更长的 allow 前缀优先于 deny。
allow:
- github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query
deny:
-19
View File
@@ -78,25 +78,6 @@ helm upgrade alisten \
--timeout 6m
```
## 开放 API
常驻房间支持生成 API Key,供第三方应用(如 IM 平台机器人)调用本项目的开放接口。调用时须通过 `Authorization: Bearer <API Key>` 请求头进行身份鉴权。API Key 仅在生成或重置时返回一次,请妥善保管。
完整的接口定义、请求参数与响应结构见 [OpenAPI 规范文件](protocol/openapi.gen.json)。
| 接口 | 说明 |
| --- | --- |
| `GET /api/room/search/tracks` | 搜索曲目 |
| `GET /api/room/search/playlists` | 搜索歌单 |
| `GET /api/room/playlists/{source}/{playlistId}/tracks` | 获取歌单内的曲目 |
| `GET /api/room/recommendations` | 获取推荐曲目 |
| `POST /api/room/queue` | 按曲目或关键词点歌 |
| `DELETE /api/room/queue/{entryId}` | 删除等待队列中的指定曲目 |
| `POST /api/room/queue/{entryId}/likes` | 为等待队列中的指定曲目点赞 |
| `POST /api/room/skip-votes` | 投票跳过当前播放的曲目 |
| `PATCH /api/room/settings` | 设置播放模式 |
| `POST /api/room/messages` | 发送房间聊天消息 |
## 使用与版权
本项目不提供音源内容及其使用授权。音源名称、音频、封面和歌词等内容的权利归各自权利人所有。
-5
View File
@@ -102,8 +102,6 @@ func (g *goDecoder) object(name string, s *schema) {
at := fmt.Sprintf("field(path, %q)", f)
decode := "decodeString"
switch {
case p.Type == "array":
decode = fmt.Sprintf("decodeArray(decode%s, %d)", refName(p.Items.Ref), *p.MaxItems)
case p.Ref != "":
decode = "decode" + refName(p.Ref)
case p.Type == "integer":
@@ -151,9 +149,6 @@ func (g *goDecoder) checks(value, at string, s *schema, patternName string) {
g.patterns = append(g.patterns, goPattern{name: name, expr: s.Pattern})
check("checkPattern", name)
}
if s.MinItems != nil {
check("checkMinItems", fmt.Sprint(*s.MinItems))
}
if s.Minimum != nil {
check("checkMinimum", fmt.Sprint(*s.Minimum))
}
+10 -43
View File
@@ -107,8 +107,6 @@ type schema struct {
Required []string `json:"required,omitempty"`
AdditionalProperties *bool `json:"additionalProperties,omitempty"`
Items *schema `json:"items,omitempty"`
MinItems *int `json:"minItems,omitempty"`
MaxItems *int `json:"maxItems,omitempty"`
AnyOf []*schema `json:"anyOf,omitempty"`
OneOf []*schema `json:"oneOf,omitempty"`
AllOf []*schema `json:"allOf,omitempty"`
@@ -150,7 +148,7 @@ func parseIDL(data []byte) (*idl, error) {
return &d, nil
}
// encoding/json 对重复的键只保留最后一个值,还会把显式的 null 解码为 nil。之后的检查和生成都假定节点存在,所以解码之前先拒绝重复的键和显式的 null。
// encoding/json 对重复的键只保留最后一个值,并把显式的 null 解码为 nil,而之后的检查和生成都假定节点存在,所以解码之前先拒绝这两种写法。
// encoding/json 匹配结构体字段时不区分大小写,会把 "MAXLENGTH" 当作 maxLength,所以关键字只接受规范的写法。
func checkJSON(dec *json.Decoder, where string, named bool) error {
tok, err := dec.Token()
@@ -193,7 +191,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 +253,7 @@ func pathParams(path string) []string {
return params
}
// 在生成的 TypeScript client 中,路径参数和列表中的名字是同一个函数里的标识符,所以不能重名。
// 生成的 TypeScript client 中,路径参数与这些名字是同一个函数中的标识符,不能重名。
var tsClientNames = []string{"body", "send", "encodeURIComponent"}
var tsFixedNames = []string{
@@ -441,47 +439,21 @@ func (d *idl) checkField(where string, s *schema, required, inbound bool) error
}
return d.checkValue(where+".anyOf[0]", s.AnyOf[0], inbound)
case s.Type == "array":
if !required {
return fmt.Errorf("%s:数组必须是必填字段", where)
if inbound || !required {
return fmt.Errorf("%s:数组只能出现在响应和事件中,并且必须是必填字段", where)
}
allowed := []string{"type", "description", "items"}
if inbound {
allowed = append(allowed, "minItems", "maxItems")
}
if err := allowOnly(where, s, allowed...); err != nil {
if err := allowOnly(where, s, "type", "description", "items"); err != nil {
return err
}
if s.Items == nil {
return fmt.Errorf("%s:数组缺少 items", where)
}
if inbound {
if err := d.checkInboundArray(where, s); err != nil {
return err
}
}
return d.checkValue(where+".items", s.Items, inbound)
default:
return d.checkValue(where, s, inbound)
}
}
// 请求中的数组元素只能引用对象或枚举类型,因为解码函数按类型名生成。
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)
}
if s.MaxItems == nil {
return fmt.Errorf("%s:请求中的数组必须写 maxItems,解码时先按它拒绝过长的数组", where)
}
if s.MinItems != nil && *s.MinItems < 0 || *s.MaxItems < 0 {
return fmt.Errorf("%s:minItems 和 maxItems 不能是负数", where)
}
if s.MinItems != nil && *s.MinItems > *s.MaxItems {
return fmt.Errorf("%s:minItems 大于 maxItems", where)
}
return nil
}
func (d *idl) checkValue(where string, s *schema, inbound bool) error {
if s.Ref != "" {
if err := allowOnly(where, s, "$ref", "description"); err != nil {
@@ -561,7 +533,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 +695,7 @@ type endpointNames struct {
}
func (n endpointNames) addRoute(where string, b httpBinding, owner string) error {
// 路径参数的名字不影响路由。两条路由如果只有参数名不同,ServeMux 登记时会 panic,OpenAPI 也把它们视为同一个 path。
// 路径参数的名字不影响路由:ServeMux 登记两条这样的路由会 panic,OpenAPI 也把它们视为同一个 path。
shape := pathParamPattern.ReplaceAllString(b.Path, "{}")
route := b.Method + " " + shape
if first, ok := n.routes[route]; ok {
@@ -772,7 +744,7 @@ func (d *idl) checkRoomRoute(where string, m *message, names endpointNames) erro
}
if b.Method == "GET" || b.Method == "DELETE" {
for _, name := range slices.Sorted(maps.Keys(m.Request.Properties)) {
if t := d.jsonType(m.Request.Properties[name]); t != "string" && t != "integer" {
if d.jsonType(m.Request.Properties[name]) == "object" {
return fmt.Errorf("%s:%s 请求的字段 %s 来自路径或查询串,应是字符串、整数或枚举", where, b.Method, name)
}
}
@@ -780,7 +752,7 @@ func (d *idl) checkRoomRoute(where string, m *message, names endpointNames) erro
return names.addRoute(where, b, "房间内请求 "+m.Type+" 的路由")
}
// 请求的字段已经检查过,不会是 null、布尔或数字,所以只有 string、integer、object 和 array 四种。
// 请求的字段已经检查过,不会是数组、布尔或 null,所以只有 string、integer 和 object 三种。
func (d *idl) jsonType(s *schema) string {
if s.Ref != "" {
return d.Defs[refName(s.Ref)].Type
@@ -990,9 +962,6 @@ func topLevelNames(f *ast.File) []string {
func (d *idl) markInbound(s *schema) error {
for _, name := range slices.Sorted(maps.Keys(s.Properties)) {
p := s.Properties[name]
if p.Type == "array" {
p = p.Items
}
if p.Ref == "" || d.inbound[refName(p.Ref)] || d.Defs[refName(p.Ref)].scalar() {
continue
}
@@ -1086,8 +1055,6 @@ func (s *schema) keywords() []string {
add(s.Required != nil, "required")
add(s.AdditionalProperties != nil, "additionalProperties")
add(s.Items != nil, "items")
add(s.MinItems != nil, "minItems")
add(s.MaxItems != nil, "maxItems")
add(s.AnyOf != nil, "anyOf")
add(s.OneOf != nil, "oneOf")
add(s.AllOf != nil, "allOf")
+6 -27
View File
@@ -143,33 +143,17 @@ func TestParseIDLRejects(t *testing.T) {
{"required 写了不存在的字段", func(d map[string]any) {
defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "string"}}, "message", "code")
}, "required 中的 code 不在 properties 里"},
{"请求中有标量数组", func(d map[string]any) {
{"请求中有数组", func(d map[string]any) {
d["x-requests"] = []any{roomRequest(object(map[string]any{
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, "ids"))}
}, "请求中的数组元素只能引用对象或枚举类型"},
{"请求引用的类型中有可选数组", func(d map[string]any) {
}, "数组只能出现在响应和事件中"},
{"请求引用的类型中有数组", func(d map[string]any) {
defs(d)["Batch"] = object(map[string]any{
"ids": map[string]any{"type": "array", "items": ref("Empty")},
})
d["x-requests"] = []any{roomRequest(object(map[string]any{"batch": ref("Batch")}, "batch"))}
}, "$defs.Batch.properties.ids:数组必须是必填字段"},
{"请求数组没有 maxItems", func(d map[string]any) {
d["x-requests"] = []any{roomRequest(object(map[string]any{
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 1},
}, "ids"))}
}, "请求中的数组必须写 maxItems"},
{"请求数组的 minItems 大于 maxItems", func(d map[string]any) {
d["x-requests"] = []any{roomRequest(object(map[string]any{
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 2, "maxItems": 1},
}, "ids"))}
}, "minItems 大于 maxItems"},
{"响应中的数组写了 minItems", func(d map[string]any) {
defs(d)["Batch"] = object(map[string]any{
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 1},
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, "ids")
d["x-events"] = []any{map[string]any{"type": "batch", "data": ref("Batch")}}
}, "不支持 minItems"},
d["x-requests"] = []any{roomRequest(object(map[string]any{"batch": ref("Batch")}, "batch"))}
}, "$defs.Batch.properties.ids:数组只能出现在响应和事件中"},
{"生成的类型名重名", func(d map[string]any) {
defs(d)["RoomSyncRequest"] = object(map[string]any{})
d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
@@ -246,11 +230,6 @@ func TestParseIDLRejects(t *testing.T) {
defs(d)["Track"] = object(map[string]any{"source": ref("Kind"), "id": str()}, "source", "id")
d["x-requests"] = []any{roomBinding("GET", "/api/room", map[string]any{"track": ref("Track")}, "track")}
}, "x-requests[0].http:GET 请求的字段 track 来自路径或查询串,应是字符串、整数或枚举"},
{"DELETE 请求有数组字段", func(d map[string]any) {
withKind(d)
kinds := map[string]any{"type": "array", "items": ref("Kind"), "maxItems": 10}
d["x-requests"] = []any{roomBinding("DELETE", "/api/room/queue", map[string]any{"kinds": kinds}, "kinds")}
}, "x-requests[0].http:DELETE 请求的字段 kinds 来自路径或查询串,应是字符串、整数或枚举"},
{"204 的房间内请求有响应", func(d map[string]any) {
m := roomBinding("POST", "/api/room/messages", map[string]any{})
m["http"].(map[string]any)["status"] = 204
+2 -3
View File
@@ -75,9 +75,8 @@ func generateFrom(path string) ([]output, error) {
return generate(d)
}
// 生成函数只处理 check 接受过的 IDL,所以直接依赖 check 保证的结构,不再重复检查。check 保证了以下几点:
// 引用都指向存在的类型(标量类型已经展开),数组都有 items,anyOf 是 [类型, {"type": "null"}],
// allOf 的每个条件只按必填的枚举字段判断,需要解码函数的类型都已标记在 inbound 中。
// 生成函数只处理 check 接受过的 IDL,直接依赖它保证的结构,不再检查:引用都指向存在的类型(标量类型已经展开),数组都有 items,
// anyOf 是 [类型, {"type": "null"}],allOf 的每个条件只按必填的枚举字段判断,需要解码函数的类型都已标记在 inbound 中。
func generate(d *idl) ([]output, error) {
types, err := goTypes(d)
if err != nil {
+1 -1
View File
@@ -120,7 +120,7 @@ func openAPIResponses(b httpBinding, s *schema) map[string]any {
}
}
// 路径参数不放进查询串和请求体。204 的请求没有响应体。
// 路径参数不在查询串和请求体中;204 的请求没有响应体。
func roomOperation(schemas map[string]any, m *message) (map[string]any, error) {
b := *m.HTTP
response := m.Response
-22
View File
@@ -48,10 +48,6 @@ func DecodeCreateMemberRequest(body []byte) (CreateMemberRequest, error) {
return decodeCreateMemberRequest(body, "")
}
func DecodeRemoveTracksRequest(body []byte) (RemoveTracksRequest, error) {
return decodeRemoveTracksRequest(body, "")
}
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
return decodeCreateRoomRequest(body, "")
}
@@ -261,24 +257,6 @@ func decodeQueueRemoveRequest(raw json.RawMessage, path string) (QueueRemoveRequ
return v, nil
}
func decodeRemoveTracksRequest(raw json.RawMessage, path string) (RemoveTracksRequest, error) {
var v RemoveTracksRequest
m, err := decodeObject(raw, path, "tracks")
if err != nil {
return v, err
}
if err = requireFields(m, path, "tracks"); err != nil {
return v, err
}
if v.Tracks, err = decodeArray(decodeTrack, 3)(m["tracks"], field(path, "tracks")); err != nil {
return v, err
}
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
return v, err
}
return v, nil
}
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
var v RoomSyncRequest
_, err := decodeObject(raw, path)
-15
View File
@@ -212,21 +212,6 @@
"required": ["member", "token"]
}
},
{
"name": "removeTracks",
"method": "POST",
"path": "/api/tracks/removals",
"auth": "none",
"status": 204,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 3}
},
"required": ["tracks"]
}
},
{
"name": "getRoom",
"method": "GET",
+1 -49
View File
@@ -183,23 +183,6 @@
},
"type": "object"
},
"RemoveTracksRequest": {
"additionalProperties": false,
"properties": {
"tracks": {
"items": {
"$ref": "#/components/schemas/Track"
},
"maxItems": 3,
"minItems": 1,
"type": "array"
}
},
"required": [
"tracks"
],
"type": "object"
},
"Room": {
"additionalProperties": false,
"description": "房间",
@@ -335,7 +318,7 @@
},
"info": {
"title": "Sample",
"version": "5e495c1e23fa03a5"
"version": "362d144af81ba8bf"
},
"openapi": "3.1.0",
"paths": {
@@ -791,37 +774,6 @@
],
"summary": "座位上的成员,<b>座位号</b> 从 1 开始 & 不超过 8"
}
},
"/api/tracks/removals": {
"post": {
"operationId": "removeTracks",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RemoveTracksRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": []
}
}
}
}
+1 -14
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "5e495c1e23fa03a5"
const Version = "362d144af81ba8bf"
type AdminUpdateRoomRequest struct {
Homepage *string `json:"homepage,omitempty"`
@@ -88,18 +88,6 @@ type QueueRemoveRequest struct {
EntryID string `json:"entryId"`
}
type RemoveTracksRequest struct {
Tracks []Track `json:"tracks"`
}
func (v RemoveTracksRequest) MarshalJSON() ([]byte, error) {
type plain RemoveTracksRequest
if v.Tracks == nil {
v.Tracks = []Track{}
}
return json.Marshal(plain(v))
}
type Room struct {
Gain float64 `json:"gain"`
ID string `json:"id"`
@@ -166,7 +154,6 @@ const (
var (
RouteCreateMember = Route{Pattern: "POST /api/members", Auth: AuthNone, Status: 201}
RouteRemoveTracks = Route{Pattern: "POST /api/tracks/removals", Auth: AuthNone, Status: 204}
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
RouteGetSeat = Route{Pattern: "GET /api/rooms/{ref}/seats/{seat}", Auth: AuthMember, Status: 200}
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
import type { ErrorInfo, Member, Room, Track } from '../realtime/protocol.gen'
import type { ErrorInfo, Member, Room } from '../realtime/protocol.gen'
export interface AdminUpdateRoomRequest {
homepage?: string
@@ -25,10 +25,6 @@ export interface CreateRoomResponse {
id: string
}
export interface RemoveTracksRequest {
tracks: Track[]
}
export interface ErrorResponse {
error: ErrorInfo
}
@@ -47,7 +43,6 @@ export type HttpSend = <T>(request: HttpRequest) => Promise<T>
export function httpClient(send: HttpSend) {
return {
createMember: () => send<CreateMemberResponse>({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
removeTracks: (body: RemoveTracksRequest) => send<void>({ method: 'POST', path: '/api/tracks/removals', auth: 'none', body }),
getRoom: (ref: string) => send<Room>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
getSeat: (ref: string, seat: string) => send<Member>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}/seats/${encodeURIComponent(seat)}`, auth: 'member' }),
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '5e495c1e23fa03a5'
export const PROTOCOL_VERSION = '362d144af81ba8bf'
export type Empty = Record<string, never>
+5 -5
View File
@@ -1,5 +1,5 @@
-- +goose Up
-- 可选的文本值没有或不适用时为 NULL。空串只表示值本身为空,例如被清空的房间描述。
-- 可选的文本值在没有或不适用时为 NULL;空串只表示值本身为空,例如被清空的房间描述。
CREATE TABLE members (
id uuid PRIMARY KEY DEFAULT uuidv7(),
token_hash bytea NOT NULL UNIQUE,
@@ -15,12 +15,12 @@ CREATE TABLE rooms (
name text NOT NULL,
description text NOT NULL,
password_hash text,
-- 每次接管房间时加 1。落盘以 term 为条件,所以过期的 owner 无法写入。
-- 接管房间时加 1;落盘以它为条件,因此过期的 owner 无法写入。
term bigint NOT NULL DEFAULT 0,
play_mode text NOT NULL DEFAULT 'sequential' CHECK (play_mode IN ('sequential', 'random', 'rotation')),
entry_seq bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
-- bot 成员在第一次生成 API Key 时创建,之后重置 Key、修改密码都不会更换。
-- 第一次生成 API Key 时创建,此后重置 Key、修改密码都不换。
bot_member_id uuid REFERENCES members,
CHECK (bot_member_id IS NULL OR kind = 'persistent')
);
@@ -46,7 +46,7 @@ CREATE TABLE room_entries (
added_at timestamptz NOT NULL,
started_at timestamptz CHECK ((slot = 'current') = (started_at IS NOT NULL)),
page_url text NOT NULL,
-- 条目的轮次在入队时定下。轮流播放时,点赞数相同的歌按轮次排列。
-- 条目的轮次在入队时定下,轮流播放时点赞数相同的歌按它排列。
round bigint NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, entry_id)
);
@@ -105,7 +105,7 @@ CREATE TABLE credentials (
updated_at timestamptz NOT NULL DEFAULT now()
);
-- site 表至多一行。没有这一行时,品牌名是默认的 Alisten。
-- 至多一行;没有这一行时品牌名是默认的 Alisten。
CREATE TABLE site (
id boolean PRIMARY KEY DEFAULT true CHECK (id),
brand_name text NOT NULL
-39
View File
@@ -1,39 +0,0 @@
-- +goose Up
-- 一个会话对应一个成员 token,同一个成员可以在多台设备上各有一个会话。
CREATE TABLE member_sessions (
token_hash bytea PRIMARY KEY,
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON member_sessions (member_id);
-- bot 成员只通过 API Key 使用 HTTP 接口,不需要会话。
INSERT INTO member_sessions (token_hash, member_id, created_at)
SELECT m.token_hash, m.id, m.created_at FROM members m
WHERE NOT EXISTS (SELECT FROM rooms r WHERE r.bot_member_id = m.id);
ALTER TABLE members DROP COLUMN token_hash;
-- 设置了用户名的成员是已注册的账号,否则是游客。用户名只能由小写字母、数字、下划线和连字符组成,所以按原文唯一即可。
-- 邮箱属于账号,在所有设备上一致。游客没有账号,不替匿名身份保存个人信息,所以游客的邮箱只存在游客自己的浏览器里。
ALTER TABLE members
ADD COLUMN username text UNIQUE,
ADD COLUMN password_hash text,
ADD CONSTRAINT members_password_check CHECK (password_hash IS NULL OR username IS NOT NULL),
ADD COLUMN email text,
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL);
-- 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;
-- 票据属于签发它的会话,会话删除时票据随之失效。bot 的 API Key 票据没有会话。
ALTER TABLE room_tickets ADD COLUMN session_token_hash bytea REFERENCES member_sessions ON DELETE CASCADE;
CREATE INDEX ON room_tickets (session_token_hash);
UPDATE room_tickets t SET session_token_hash = s.token_hash
FROM member_sessions s
WHERE s.member_id = t.member_id;
-35
View File
@@ -1,35 +0,0 @@
-- +goose Up
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 作为排序值,排在最前。
item_seq bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
-- 歌单改名或曲目增减的时间。
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON playlists (owner_id);
CREATE UNIQUE INDEX ON playlists (owner_id) WHERE kind = 'favorites';
-- 展示用的元数据存一份快照,因为歌单里的歌不一定准备过,tracks 表里可能没有。
CREATE TABLE playlist_items (
playlist_id uuid NOT NULL REFERENCES playlists ON DELETE CASCADE,
source text NOT NULL,
track_id text NOT NULL,
title text NOT NULL,
artist text,
album text,
cover_url text,
cover_key text,
duration_ms bigint NOT NULL,
page_url text NOT NULL,
position bigint NOT NULL,
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (playlist_id, source, track_id)
);
CREATE INDEX ON playlist_items (playlist_id, position DESC);
-21
View File
@@ -1,21 +0,0 @@
-- +goose Up
-- 关闭之后不再记录新的播放历史,已有的保留。
ALTER TABLE members ADD COLUMN record_listens boolean NOT NULL DEFAULT true;
-- 成员在房间里听到过的歌,每首只留最近一次。元数据存一份快照,不关联 tracks,因为管理员删除已存储的媒体时会连带删掉 tracks 中对应的行。
CREATE TABLE listens (
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
source text NOT NULL,
track_id text NOT NULL,
title text NOT NULL,
artist text,
album text,
cover_url text,
cover_key text,
duration_ms bigint NOT NULL,
page_url text NOT NULL,
heard_at timestamptz NOT NULL,
PRIMARY KEY (member_id, source, track_id)
);
CREATE INDEX ON listens (member_id, heard_at DESC);
-3
View File
@@ -1,3 +0,0 @@
-- +goose Up
-- 分享链接中的随机串,NULL 表示没有分享。明文保存,这样所有者随时可以再复制一次链接。
ALTER TABLE playlists ADD COLUMN share_id text UNIQUE;
-6
View File
@@ -1,6 +0,0 @@
-- +goose Up
-- 绑定了 GitHub 的成员也是已注册的账号,可以没有用户名和密码。所以账号的邮箱也可以属于只绑定了 GitHub 的成员。
ALTER TABLE members
ADD COLUMN github_id bigint UNIQUE,
DROP CONSTRAINT members_email_check,
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL OR github_id IS NOT NULL);
+2 -2
View File
@@ -1,6 +1,6 @@
-- name: LockRoomBot :one
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE,因为同一个房间的重置要依次进行。
-- 这样同时到达的首次生成只会创建一个 bot 成员,最后只留下一把 Key。
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个房间的重置要依次进行,
-- 同时到达的首次生成才只会创建一个 bot 成员,最后只留下一把 Key。
SELECT bot_member_id FROM rooms WHERE id = @id FOR NO KEY UPDATE;
-- name: SetRoomBot :exec
+1 -1
View File
@@ -1,5 +1,5 @@
-- name: AutoDJSeeds :many
-- 只看最近 1000 次播放。查询按 plays (room_id, started_at) 的索引倒序读,读到 1000 行就停,所以耗时与历史的长度无关。
-- 只看最近 1000 次播放:按 plays (room_id, started_at) 的索引倒序读到 1000 行就停,耗时与历史的长度无关。
SELECT track_id FROM (
SELECT DISTINCT ON (track_id) track_id, tier, rank
FROM (
-57
View File
@@ -1,57 +0,0 @@
-- name: LockListeners :many
-- 按 ID 的顺序锁住开启了记录的成员。这样同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删。
-- 加锁的顺序固定,所以同时写几个成员的事务之间不会死锁。
SELECT id FROM members WHERE id = ANY(@ids::uuid[]) AND record_listens ORDER BY id FOR NO KEY UPDATE;
-- name: UpsertListens :exec
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT m, @source::text, @track_id::text, @title::text, sqlc.narg(artist)::text, sqlc.narg(album)::text, sqlc.narg(cover_url)::text,
sqlc.narg(cover_key)::text, @duration_ms::bigint, @page_url::text, @heard_at::timestamptz
FROM unnest(@member_ids::uuid[]) AS m
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: MoveListens :exec
-- 并入的规则与 UpsertListens 相同,并入的那份算新写入的。to_id 关闭了记录时只删不并。
WITH moved AS (
DELETE FROM listens WHERE member_id = sqlc.arg(from_id)::uuid
RETURNING source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
)
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT sqlc.arg(to_id)::uuid, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM moved
WHERE EXISTS (SELECT FROM members WHERE id = sqlc.arg(to_id)::uuid AND record_listens)
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: TrimListens :exec
-- 排序与 MemberListens 相同,所以留下的正是读取时返回的那些。
DELETE FROM listens l
USING (
SELECT member_id, source, track_id,
row_number() OVER (PARTITION BY member_id ORDER BY heard_at DESC, source, track_id) AS n
FROM listens WHERE member_id = ANY(@member_ids::uuid[])
) ranked
WHERE l.member_id = ranked.member_id AND l.source = ranked.source AND l.track_id = ranked.track_id AND ranked.n > @keep::bigint;
-- name: MemberListens :many
SELECT source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM listens WHERE member_id = @member_id
ORDER BY heard_at DESC, source, track_id
LIMIT @max;
-- name: DeleteListen :exec
DELETE FROM listens WHERE member_id = @member_id AND source = @source AND track_id = @track_id;
-- name: ClearListens :exec
DELETE FROM listens WHERE member_id = @member_id;
-- name: ListenRecording :one
SELECT record_listens FROM members WHERE id = @id;
-- name: SetListenRecording :execrows
UPDATE members SET record_listens = @record_listens WHERE id = @id;
+4 -13
View File
@@ -1,19 +1,10 @@
-- name: ListStoredMedia :many
-- 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\'
ORDER BY CASE WHEN @sort::text = 'stored' THEN prepared_at END DESC,
CASE WHEN @sort::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
CASE WHEN @sort::text = 'size' THEN media_size END DESC,
track_id COLLATE "C"
LIMIT @page_size::bigint OFFSET @skip::bigint;
-- name: CountStoredMedia :one
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\';
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL
ORDER BY prepared_at DESC, track_id;
-- name: StoredMedia :one
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key = @media_key::text;
-- name: DeleteStoredMedia :exec
+4 -66
View File
@@ -1,76 +1,14 @@
-- name: CreateMember :one
INSERT INTO members (name) VALUES (@name)
INSERT INTO members (token_hash, name) VALUES (@token_hash, @name)
RETURNING *;
-- name: CreateSession :exec
INSERT INTO member_sessions (token_hash, member_id) VALUES (@token_hash, @member_id);
-- name: MemberByToken :one
SELECT * FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = @token_hash);
SELECT * FROM members WHERE token_hash = @token_hash;
-- name: UpdateMember :one
-- 只覆盖传入的列,所以分别修改昵称和头像的两个并发请求不会互相还原。头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。
UPDATE members
SET name = coalesce(sqlc.narg(name), name),
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END,
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END
WHERE id = @id
RETURNING *;
-- name: MemberAccountProfile :one
SELECT username, email, github_id FROM members WHERE id = @id;
-- name: RegisterMember :execrows
-- 只给游客设置。只绑定了 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 的账号设置。这种账号的“我喜欢”已经在绑定时创建了。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL AND github_id IS NOT NULL;
-- name: AccountByUsername :one
SELECT * FROM members WHERE username = @username;
-- name: AccountByID :one
SELECT * FROM members WHERE id = @id AND (username IS NOT NULL OR github_id IS NOT NULL);
-- name: AccountByGitHub :one
SELECT * FROM members WHERE github_id = @github_id;
-- name: BindGitHub :one
-- 只给还没有绑定 GitHub 的成员绑定。已经绑定过的成员不受影响,查询没有返回的行。was_guest 表示成员原来是游客,这次绑定就是注册。
UPDATE members SET github_id = @github_id
WHERE id = @id AND github_id IS NULL
RETURNING (username IS NULL)::boolean AS was_guest;
-- name: UnbindGitHub :execrows
-- 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
UPDATE members SET github_id = NULL WHERE id = @id AND password_hash IS NOT NULL;
-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = @password_hash WHERE id = @id AND username IS NOT NULL;
-- name: LockAccountPassword :one
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE。这样同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id AND username IS NOT NULL FOR NO KEY UPDATE;
-- name: MemberHasSession :one
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = @token_hash AND member_id = @member_id);
-- name: LockMemberPassword :one
-- 这把锁让修改密码等到这次登录提交之后才进行,所以修改密码随后删除会话时,会删掉这次登录签发的会话。
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE,这样同一个账号的几次登录会依次并入游客的历史。否则两次登录会按相反的顺序抢同一批历史行而死锁。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR NO KEY UPDATE;
-- name: DeleteSession :execrows
DELETE FROM member_sessions WHERE token_hash = @token_hash;
-- name: TakeSession :one
DELETE FROM member_sessions WHERE token_hash = @token_hash RETURNING member_id;
-- name: DeleteMemberSessions :many
-- keep 为 NULL 时删除这个成员的全部会话。
DELETE FROM member_sessions WHERE member_id = @member_id AND token_hash IS DISTINCT FROM sqlc.narg(keep)::bytea
RETURNING token_hash;
-140
View File
@@ -1,140 +0,0 @@
-- name: CreateFavorites :exec
INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'favorites', '我喜欢');
-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id
GROUP BY p.id
ORDER BY p.kind = 'favorites' DESC, p.created_at, p.id;
-- name: MemberPlaylistCovers :many
SELECT DISTINCT ON (i.playlist_id) i.playlist_id, i.cover_url, i.cover_key
FROM playlist_items i
JOIN playlists p ON p.id = i.playlist_id
WHERE p.owner_id = @owner_id AND (i.cover_url IS NOT NULL OR i.cover_key IS NOT NULL)
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
SELECT count(*) FROM playlists WHERE owner_id = @owner_id;
-- name: CreatePlaylist :one
INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'custom', @name)
RETURNING id, created_at, updated_at;
-- name: PlaylistKind :one
SELECT kind FROM playlists WHERE id = @id AND owner_id = @owner_id;
-- name: RenamePlaylist :execrows
UPDATE playlists SET name = @name, updated_at = now() WHERE id = @id AND owner_id = @owner_id AND kind = 'custom';
-- name: DeletePlaylist :execrows
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
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = @playlist_id AND source = @source AND track_id = @track_id);
-- name: CountPlaylistItems :one
SELECT count(*) FROM playlist_items WHERE playlist_id = @playlist_id;
-- name: InsertPlaylistItem :exec
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
-- name: InsertPlaylistItems :copyfrom
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
-- name: SetPlaylistSeq :exec
UPDATE playlists SET item_seq = @item_seq, updated_at = now() WHERE id = @id;
-- name: PlaylistItems :many
SELECT * FROM playlist_items WHERE playlist_id = @playlist_id ORDER BY position DESC;
-- name: DeletePlaylistItems :exec
WITH deleted AS (
DELETE FROM playlist_items i
USING unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS t(track_id, ord) USING (ord)
WHERE i.playlist_id = @playlist_id AND i.source = s.source AND i.track_id = t.track_id
RETURNING i.playlist_id
)
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)
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
GROUP BY s.source, k.track_id
), copied AS (
SELECT i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY w.ord DESC) AS rank
FROM wanted w
JOIN playlist_items i ON i.playlist_id = @from_id AND i.source = w.source AND i.track_id = w.track_id
WHERE NOT EXISTS (
SELECT FROM playlist_items d WHERE d.playlist_id = @to_id AND d.source = w.source AND d.track_id = w.track_id
)
)
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT sqlc.arg(to_id), source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, sqlc.arg(item_seq)::bigint + rank
FROM copied;
-- name: PlaylistTracks :many
SELECT source, track_id FROM playlist_items WHERE playlist_id = @playlist_id ORDER BY position DESC;
-- name: SetPlaylistPositions :exec
UPDATE playlist_items i SET position = t.position
FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN unnest(@positions::bigint[]) WITH ORDINALITY AS t(position, ord) USING (ord)
WHERE i.playlist_id = @playlist_id AND i.source = s.source AND i.track_id = k.track_id;
-- name: TouchPlaylist :exec
UPDATE playlists SET updated_at = now() WHERE id = @id;
-- name: TrackPlaylists :many
SELECT p.id FROM playlists p
JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id AND i.source = @source AND i.track_id = @track_id
ORDER BY p.id;
-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = @share_id WHERE id = @id AND owner_id = @owner_id;
-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = sqlc.arg(share_id)::text;
-- name: CopySharedPlaylistItems :one
-- 确认分享还在和复制曲目放在同一条语句里,两者看到的是同一个快照,所以要么完整复制,要么读不到分享。不锁来源歌单,因为两个人同时复制
-- 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
WITH shared AS (
SELECT id FROM playlists WHERE share_id = sqlc.arg(share_id)::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT sqlc.arg(to_id), i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied;
-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = @playlist_id AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1;
+2 -6
View File
@@ -1,10 +1,6 @@
-- name: SiteSettings :one
SELECT brand_name, registration_open FROM site;
-- name: BrandName :one
SELECT brand_name FROM site;
-- name: SetBrandName :exec
INSERT INTO site (brand_name) VALUES (@brand_name)
ON CONFLICT (id) DO UPDATE SET brand_name = excluded.brand_name;
-- name: SetRegistrationOpen :exec
INSERT INTO site (registration_open) VALUES (@registration_open)
ON CONFLICT (id) DO UPDATE SET registration_open = excluded.registration_open;
+3 -8
View File
@@ -1,18 +1,13 @@
-- 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
-- bot 的 API Key 没有会话,session_token_hash 为 NULL。
INSERT INTO room_tickets (token_hash, room_id, member_id, session_token_hash)
VALUES (@token_hash, @room_id, @member_id, sqlc.narg(session_token_hash));
INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES (@token_hash, @room_id, @member_id);
-- name: TicketHolder :one
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot, t.session_token_hash
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot
FROM room_tickets t
JOIN members m ON m.id = t.member_id
JOIN rooms r ON r.id = t.room_id
WHERE t.token_hash = @token_hash;
-- name: ExistingTickets :many
SELECT token_hash FROM room_tickets WHERE token_hash = ANY(@token_hashes::bytea[]);
+1 -7
View File
@@ -3,7 +3,7 @@ SELECT title, artist, album, cover_url, cover_key, page_url, duration_ms, gain,
WHERE source = @source AND track_id = @track_id;
-- name: SavePreparedTrack :exec
-- 准备任务只对未完成的任务去重,所以同一首歌可能先后准备两次,以后一次的结果为准。
-- 准备任务只对未完成的任务去重,同一首歌可能先后准备两次,以后一次的结果为准。
INSERT INTO tracks (source, track_id, title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, lyric_words, lyric_trans, media_key, media_size)
VALUES (@source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @page_url, @duration_ms, @gain, @lyric, @lyric_words, @lyric_trans, @media_key, @media_size)
ON CONFLICT (source, track_id) DO UPDATE SET
@@ -12,9 +12,3 @@ ON CONFLICT (source, track_id) DO UPDATE SET
lyric = excluded.lyric, lyric_words = excluded.lyric_words, lyric_trans = excluded.lyric_trans,
media_key = excluded.media_key, media_size = excluded.media_size,
prepared_at = now();
-- name: TrackMetas :many
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id;
-110
View File
@@ -1,110 +0,0 @@
package api
import (
"context"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) register(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRegisterRequest(c.body)
if err != nil {
return nil, err
}
account, err := s.Accounts.Register(ctx, c.member.ID, bearer(c.r), c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
return protocol.RegisterResponse{Account: accountDTO(account)}, nil
}
func (s *server) login(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeLoginRequest(c.body)
if err != nil {
return nil, err
}
session, account, device, err := s.Accounts.Login(ctx, c.member, bearer(c.r), c.clientIP, req.Username, req.Password, deref(req.Device))
if err != nil {
return nil, err
}
return protocol.LoginResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account), Device: device}, nil
}
func (s *server) logout(ctx context.Context, c call) (any, error) {
return nil, s.Accounts.Logout(ctx, bearer(c.r))
}
func (s *server) changePassword(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeChangePasswordRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Accounts.ChangePassword(ctx, c.member.ID, bearer(c.r), req.CurrentPassword, req.NewPassword)
}
func (s *server) adminGetAccount(ctx context.Context, c call) (any, error) {
username := c.r.PathValue("username")
if !usernamePattern.MatchString(username) {
return nil, identity.ErrAccountNotFound
}
account, err := s.Accounts.Find(ctx, username)
if err != nil {
return nil, err
}
return protocol.AdminAccount{Member: memberDTO(account.Member), Username: account.Username}, nil
}
func (s *server) adminResetAccountPassword(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeAdminResetAccountPasswordRequest(c.body); err != nil {
return nil, err
}
username := c.r.PathValue("username")
if !usernamePattern.MatchString(username) {
return nil, identity.ErrAccountNotFound
}
password, err := s.Accounts.ResetPassword(ctx, username)
if err != nil {
return nil, err
}
return protocol.AdminResetAccountPasswordResponse{Password: password}, nil
}
func (s *server) createPairingCode(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeCreatePairingCodeRequest(c.body); err != nil {
return nil, err
}
code, err := s.Accounts.CreatePairingCode(ctx, c.member.ID, bearer(c.r))
if err != nil {
return nil, err
}
// 配对码放在片段里,不会出现在请求和 Referer 中。
image, err := qrDataURI(s.PublicURL + "/pair#" + code.Code)
if err != nil {
return nil, err
}
return protocol.CreatePairingCodeResponse{Code: code.Code, QrImage: image, ExpiresAtMs: code.ExpiresAt.UnixMilli()}, nil
}
func (s *server) redeemPairingCode(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRedeemPairingCodeRequest(c.body)
if err != nil {
return nil, err
}
session, account, err := s.Accounts.RedeemPairingCode(ctx, c.member, bearer(c.r), c.clientIP, req.Code)
if err != nil {
return nil, err
}
return protocol.RedeemPairingCodeResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account)}, nil
}
func accountDTO(a identity.Account) protocol.Account {
dto := protocol.Account{Github: a.GitHub}
if a.Username != "" {
dto.Username = &a.Username
}
if a.Email != "" {
dto.Email = &a.Email
}
return dto
}
+25 -115
View File
@@ -19,47 +19,34 @@ import (
"github.com/dihemoe/alisten/internal/app/admin"
"github.com/dihemoe/alisten/internal/app/catalog"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/listen"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/internal/app/site"
"github.com/dihemoe/alisten/internal/domain/member"
playlistorder "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/room"
"github.com/dihemoe/alisten/internal/lib/logctx"
"github.com/dihemoe/alisten/protocol"
)
const (
maxBodyBytes = 64 << 10
// 歌单的批量接口一次最多 1000 首,链接曲目的 ID 最长 2048 字节,每首再留 256 字节给来源、字段名和 JSON 的标点。
maxBatchBodyBytes = 1000 * (2048 + 256)
// 导入文件一次最多 1000 首,每首除了曲目还带标题、歌手、专辑和两个地址。nginx 中 /api/me/playlists/imports 的上限要与它保持一致。
maxImportBodyBytes = 1 << 20
requestDeadline = 3 * time.Minute
maxBodyBytes = 64 << 10
requestDeadline = 3 * time.Minute
)
type Services struct {
Identity *identity.Service
Accounts *identity.Accounts
Playlists *playlist.Service
Lobby *lobby.Service
Admin *admin.Service
Gateway *realtime.Gateway
Catalog *catalog.Service
Media *media.Library
Playback *media.Playback
Site *site.Service
Listens *listen.Service
GitHub *identity.GitHub
Identity *identity.Service
Lobby *lobby.Service
Admin *admin.Service
Gateway *realtime.Gateway
Catalog *catalog.Service
Media *media.Library
Playback *media.Playback
Site *site.Service
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
StorageURL string
// 网页的 origin,结尾没有 /。
PublicURL string
}
type server struct {
@@ -76,12 +63,8 @@ type call struct {
member member.Member
roomID string
ticket string
// 持有这张票据的成员会话的 token 哈希。API Key 没有会话。
memberSession []byte
// 票据是常驻房间的 API Key。
bot bool
// 发出请求的客户端地址,按来源限流时使用。
clientIP string
}
type handlerFunc func(ctx context.Context, c call) (any, error)
@@ -113,23 +96,6 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteCreateMember, srv.createMember)
srv.handle(mux, protocol.RouteGetMe, srv.getMe)
srv.handle(mux, protocol.RouteUpdateMe, srv.updateMe)
srv.handle(mux, protocol.RouteRegister, srv.register)
srv.handle(mux, protocol.RouteChangePassword, srv.changePassword)
srv.handle(mux, protocol.RouteLogin, srv.login)
srv.handle(mux, protocol.RouteLogout, srv.logout)
srv.handle(mux, protocol.RouteListMyPlaylists, srv.listMyPlaylists)
srv.handle(mux, protocol.RouteCreateMyPlaylist, srv.createMyPlaylist)
srv.handle(mux, protocol.RouteRenameMyPlaylist, srv.renameMyPlaylist)
srv.handle(mux, protocol.RouteDeleteMyPlaylist, srv.deleteMyPlaylist)
srv.handle(mux, protocol.RouteListMyPlaylistItems, srv.listMyPlaylistItems)
srv.handle(mux, protocol.RouteAddMyPlaylistItem, srv.addMyPlaylistItem)
srv.handleLimit(mux, protocol.RouteRemoveMyPlaylistItems, maxBatchBodyBytes, srv.removeMyPlaylistItems)
srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists)
srv.handleLimit(mux, protocol.RouteCopyMyPlaylistItems, maxBatchBodyBytes, srv.copyMyPlaylistItems)
srv.handleLimit(mux, protocol.RouteMoveMyPlaylistItems, maxBatchBodyBytes, srv.moveMyPlaylistItems)
srv.handleLimit(mux, protocol.RouteImportMyPlaylist, maxImportBodyBytes, srv.importMyPlaylist)
srv.handle(mux, protocol.RouteResolveMyPlaylistLink, srv.resolveMyPlaylistLink)
srv.handle(mux, protocol.RouteImportMyPlatformPlaylist, srv.importMyPlatformPlaylist)
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -156,43 +122,21 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminGetYoutubeCredential, srv.adminGetYoutubeCredential)
srv.handle(mux, protocol.RouteAdminSetYoutubeCredential, srv.adminSetYoutubeCredential)
srv.handle(mux, protocol.RouteAdminDeleteYoutubeCredential, srv.adminDeleteYoutubeCredential)
srv.handle(mux, protocol.RouteAdminGetGithubCredential, srv.adminGetGithubCredential)
srv.handle(mux, protocol.RouteAdminSetGithubCredential, srv.adminSetGithubCredential)
srv.handle(mux, protocol.RouteAdminDeleteGithubCredential, srv.adminDeleteGithubCredential)
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
srv.handle(mux, protocol.RouteAdminRemoveMedia, srv.adminRemoveMedia)
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
srv.handle(mux, protocol.RouteAdminOpenRegistration, srv.adminOpenRegistration)
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
srv.handle(mux, protocol.RouteAdminGetAccount, srv.adminGetAccount)
srv.handle(mux, protocol.RouteAdminResetAccountPassword, srv.adminResetAccountPassword)
srv.handle(mux, protocol.RouteListMyListens, srv.listMyListens)
srv.handle(mux, protocol.RouteRemoveMyListen, srv.removeMyListen)
srv.handle(mux, protocol.RouteClearMyListens, srv.clearMyListens)
srv.handle(mux, protocol.RouteEnableListenRecording, srv.enableListenRecording)
srv.handle(mux, protocol.RouteDisableListenRecording, srv.disableListenRecording)
srv.handle(mux, protocol.RouteShareMyPlaylist, srv.shareMyPlaylist)
srv.handle(mux, protocol.RouteUnshareMyPlaylist, srv.unshareMyPlaylist)
srv.handle(mux, protocol.RouteGetSharedPlaylist, srv.getSharedPlaylist)
srv.handle(mux, protocol.RouteCopySharedPlaylist, srv.copySharedPlaylist)
srv.handle(mux, protocol.RouteCreatePairingCode, srv.createPairingCode)
srv.handle(mux, protocol.RouteRedeemPairingCode, srv.redeemPairingCode)
srv.handle(mux, protocol.RouteStartGithubLogin, srv.startGithubLogin)
srv.handle(mux, protocol.RouteFinishGithubLogin, srv.finishGithubLogin)
srv.handle(mux, protocol.RouteUnbindGithub, srv.unbindGithub)
mux.HandleFunc("GET "+GitHubCallbackPath, srv.githubCallback)
mux.HandleFunc("GET /ws", srv.websocket)
mux.HandleFunc("GET /api/room/events", srv.roomEvents)
mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia)
// HTTP 的错误响应体是 JSON,而 ServeMux 自带的 404、405 响应是纯文本。不带方法的兜底路由也会匹配方法不符的请求,
// 所以这些请求得到的也是 404,因为错误码里没有和 405 对应的。
// 因此它们得到 404:错误码中没有与 405 对应的。
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
writeError(w, protocol.ErrorInfo{Code: protocol.ErrorCodeNotFound, Message: "接口不存在"})
})
return mux
}
// 不带 Origin 的请求不检查,因为 bot 从服务端发起的调用没有 Origin。
// 不带 Origin 的请求不检查:bot 从服务端发起的调用没有 Origin。
func SameOrigin(allowedOrigins []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" && !slices.Contains(allowedOrigins, origin) {
@@ -204,14 +148,10 @@ func SameOrigin(allowedOrigins []string, next http.Handler) http.Handler {
}
func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc) {
s.handleLimit(mux, route, maxBodyBytes, fn)
}
func (s *server) handleLimit(mux *http.ServeMux, route protocol.Route, maxBody int64, fn handlerFunc) {
mux.HandleFunc(route.Pattern, func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
defer cancel()
ctx, resp, err := s.serve(ctx, w, r, route.Auth, maxBody, func(ctx context.Context, c call) (any, error) {
ctx, resp, err := s.serve(ctx, w, r, route.Auth, func(ctx context.Context, c call) (any, error) {
resp, err := fn(ctx, c)
if err != nil || !route.Location {
return resp, err
@@ -232,15 +172,15 @@ func (s *server) handleLimit(mux *http.ServeMux, route protocol.Route, maxBody i
})
}
// 先鉴权再读请求体,这样后台没有启用时,不管请求是否合法都返回 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) {
// 先鉴权再读请求体:后台没有启用时,无论请求是否合法都返回 FORBIDDEN。返回的 ctx 带有凭证所属的成员和房间,
// 出错时的日志也要带上它们。
func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, fn handlerFunc) (logCtx context.Context, _ any, err error) {
defer func() {
if value := recover(); value != nil {
logCtx, err = ctx, s.panicked(ctx, r, value)
}
}()
c := call{r: r, clientIP: clientIP(r)}
c := call{r: r}
switch auth {
case protocol.AuthNone:
case protocol.AuthMember:
@@ -256,7 +196,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque
if err != nil {
return ctx, nil, err
}
c.roomID, c.member, c.memberSession, c.bot = holder.RoomID, holder.Member, holder.SessionTokenHash, holder.Bot
c.roomID, c.member, c.bot = holder.RoomID, holder.Member, holder.Bot
ctx = logctx.WithMember(logctx.WithRoom(ctx, holder.RoomID), holder.Member.ID)
case protocol.AuthAdmin:
if err := s.Admin.Authorize(bearer(r)); err != nil {
@@ -267,7 +207,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque
return ctx, nil, fmt.Errorf("不支持的凭证类型 %d", auth)
}
if r.Method != http.MethodGet && r.Method != http.MethodDelete {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
if err != nil {
return ctx, nil, bodyError(r, err)
}
@@ -297,8 +237,8 @@ var errUnreadableBody = errors.New("无法读取请求体")
// 恢复 panic 的地方已经记录了日志,回复时不再重复记录。
var errPanicked = errors.New("服务器出错了,请稍后再试。")
// 无法读取请求体是客户端的问题,不是内部错误。如果是连接读取失败,net/http 在返回错误之前已经取消了
// r.Context(),这时按客户端断开处理。如果连接还在,就是请求体本身不合法,比如分块编码有误。
// 无法读取请求体是客户端的问题,不是内部错误。连接读取失败时,net/http 在返回错误之前已经取消了
// r.Context(),此时按客户端断开处理;连接仍在时,是请求体本身不合法,比如分块编码有误。
func bodyError(r *http.Request, err error) error {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
@@ -326,7 +266,9 @@ var knownErrors = []struct {
{errUnreadableBody, protocol.ErrorCodeInvalidArgument},
{port.ErrRoomNotFound, protocol.ErrorCodeNotFound},
{port.ErrSlugTaken, protocol.ErrorCodeConflict},
{media.ErrNotFound, protocol.ErrorCodeNotFound},
{media.ErrNotInRoom, protocol.ErrorCodeNotFound},
{media.ErrInUse, protocol.ErrorCodeConflict},
{identity.ErrInvalidMemberToken, protocol.ErrorCodeUnauthorized},
{identity.ErrWrongPassword, protocol.ErrorCodeUnauthorized},
{admin.ErrInvalidToken, protocol.ErrorCodeUnauthorized},
@@ -334,29 +276,6 @@ var knownErrors = []struct {
{admin.ErrUnrecognizedCookies, protocol.ErrorCodeInvalidArgument},
{identity.ErrInvalidTicket, protocol.ErrorCodeUnauthorized},
{identity.ErrBotSession, protocol.ErrorCodeUnauthorized},
{identity.ErrInvalidEmail, protocol.ErrorCodeInvalidArgument},
{identity.ErrInvalidCredentials, protocol.ErrorCodeInvalidCredentials},
{identity.ErrWrongCurrentPassword, protocol.ErrorCodeInvalidCredentials},
{identity.ErrAlreadyLoggedIn, protocol.ErrorCodeConflict},
{identity.ErrRegistrationClosed, protocol.ErrorCodeForbidden},
{identity.ErrNotRegistered, protocol.ErrorCodeForbidden},
{identity.ErrTooManyAttempts, protocol.ErrorCodeRateLimited},
{identity.ErrAccountNotFound, protocol.ErrorCodeNotFound},
{port.ErrUsernameTaken, protocol.ErrorCodeConflict},
{port.ErrAlreadyRegistered, protocol.ErrorCodeConflict},
{port.ErrPlaylistNotFound, protocol.ErrorCodeNotFound},
{port.ErrFavoritesFixed, protocol.ErrorCodeConflict},
{playlist.ErrNotRegistered, protocol.ErrorCodeForbidden},
{playlist.ErrInvalidName, protocol.ErrorCodeInvalidArgument},
{playlist.ErrTooManyPlaylists, protocol.ErrorCodeConflict},
{playlist.ErrPlaylistFull, protocol.ErrorCodeConflict},
{playlist.ErrTrackNotReady, protocol.ErrorCodeConflict},
{playlist.ErrTooFast, protocol.ErrorCodeRateLimited},
{playlist.ErrInvalidPlaylistLink, protocol.ErrorCodeInvalidArgument},
{playlist.ErrUnsupportedPlaylist, protocol.ErrorCodeInvalidArgument},
{playlist.ErrShortLinkFailed, protocol.ErrorCodeProviderError},
{playlistorder.ErrBeforeMoved, protocol.ErrorCodeInvalidArgument},
{playlistorder.ErrBeforeMissing, protocol.ErrorCodeConflict},
{realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument},
{site.ErrInvalidBrandName, protocol.ErrorCodeInvalidArgument},
{realtime.ErrUnavailable, protocol.ErrorCodeUnavailable},
@@ -366,15 +285,6 @@ var knownErrors = []struct {
{catalog.ErrRateLimited, protocol.ErrorCodeRateLimited},
{errClientOutdated, protocol.ErrorCodeClientOutdated},
{errPanicked, protocol.ErrorCodeInternal},
{port.ErrShareNotFound, protocol.ErrorCodeNotFound},
{identity.ErrInvalidPairingCode, protocol.ErrorCodeInvalidCredentials},
{port.ErrNoPassword, protocol.ErrorCodeConflict},
{identity.ErrGitHubLoginExpired, protocol.ErrorCodeInvalidCredentials},
{identity.ErrGitHubMismatch, protocol.ErrorCodeInvalidCredentials},
{identity.ErrGitHubUnverified, protocol.ErrorCodeForbidden},
{identity.ErrGitHubDisabled, protocol.ErrorCodeNotFound},
{port.ErrGitHubTaken, protocol.ErrorCodeConflict},
{port.ErrGitHubAlreadyBound, protocol.ErrorCodeConflict},
}
func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
@@ -384,7 +294,7 @@ func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
}
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: fmt.Sprintf("请求体不能超过 %d KiB", tooLarge.Limit>>10)}, true
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: "请求体不能超过 64 KiB"}, true
}
var roomErr *room.Error
if errors.As(err, &roomErr) {
+3 -60
View File
@@ -22,7 +22,6 @@ import (
"github.com/dihemoe/alisten/internal/app/admin"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/app/realtime"
@@ -35,23 +34,19 @@ import (
const allowedOrigin = "https://allowed.example"
// 没有调用 realtime.Start,因为这些测试不运行房间。
// 没有调用 realtime.Start:这些测试不运行房间。
func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string) Services {
music := newCatalog(rooms, 10)
node := porttest.NewRedis().Node("a")
_, gateway := realtime.New(store, rooms, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
_, gateway := realtime.New(store, rooms, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
l := lobby.New(rooms, node)
accounts := identity.NewAccounts(store, store, nil, nil, nil, nil, gateway)
return Services{
Identity: identity.New(store, rooms, store, gateway),
Accounts: accounts,
Lobby: l,
Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
Gateway: gateway,
Catalog: music,
Site: site.New(store),
// 这些测试不发起 GitHub 授权,只读取后台是否配置了 OAuth App。
GitHub: identity.NewGitHub(accounts, store, nil, nil),
}
}
@@ -158,58 +153,6 @@ func TestRequestBodyLimit(t *testing.T) {
}
}
func TestPlaylistBatchBodyLimit(t *testing.T) {
store := porttest.New()
s := services(store, store, "")
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil, nil)
server, _ := start(t, s)
var created protocol.CreateMemberResponse
if err := json.Unmarshal([]byte(send(t, server, http.MethodPost, "/api/members", "{}").body), &created); err != nil {
t.Fatal(err)
}
if err := store.Register(t.Context(), created.Member.ID, "alice", "hash"); err != nil {
t.Fatal(err)
}
auth := []string{"Authorization", "Bearer " + created.MemberToken}
lists, err := store.MemberPlaylists(t.Context(), created.Member.ID)
if err != nil {
t.Fatal(err)
}
favorites := lists[0].ID
other, err := store.CreatePlaylist(t.Context(), created.Member.ID, "通勤", 50)
if err != nil {
t.Fatal(err)
}
tracks := make([]string, 1000)
for i := range tracks {
tracks[i] = fmt.Sprintf(`{"source":"link","id":"https://www.youtube.com/watch?v=%011d&list=PL%032d"}`, i, i)
}
list := "[" + strings.Join(tracks, ",") + "]"
if len(list) <= 64<<10 {
t.Fatalf("1000 首链接只有 %d 字节,测不出 64 KiB 的上限", len(list))
}
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", `{"tracks":`+list+`}`, auth...); got.status != http.StatusNoContent {
t.Errorf("移出 1000 首链接 = %#v,期望成功", got)
}
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/moves", `{"tracks":`+list+`}`, auth...); got.status != http.StatusNoContent {
t.Errorf("移动 1000 首链接 = %#v,期望成功", got)
}
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+other.ID+"/copies", `{"from":"`+favorites+`","tracks":`+list+`}`, auth...); got != (response{status: http.StatusOK, body: `{"added":0}`}) {
t.Errorf("复制 1000 首链接 = %#v,期望成功", got)
}
tooLarge := `{"tracks":[]}` + strings.Repeat(" ", maxBatchBodyBytes)
want := failure(protocol.ErrorCodeInvalidArgument, fmt.Sprintf("请求体不能超过 %d KiB", maxBatchBodyBytes>>10))
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", tooLarge, auth...); got != want {
t.Errorf("超过批量上限的请求体 = %#v,期望 %#v", got, want)
}
want = failure(protocol.ErrorCodeInvalidArgument, "请求体不能超过 64 KiB")
if got := send(t, server, http.MethodPost, "/api/me/playlist-memberships", `{"tracks":`+list+`}`, auth...); got != want {
t.Errorf("其他接口超过 64 KiB 的请求体 = %#v,期望 %#v", got, want)
}
}
func TestMalformedPathParametersAreNotFound(t *testing.T) {
store := porttest.New()
server, logs := start(t, services(store, store, ""))
@@ -355,7 +298,7 @@ func TestAbortHandlerIsLeftToNetHTTP(t *testing.T) {
type brokenProfiles struct{ *porttest.Store }
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string, *string) (member.Member, error) {
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string) (member.Member, error) {
return member.Member{}, errors.New("连接中断")
}
+1 -1
View File
@@ -41,7 +41,7 @@ func (s *server) answer(ctx context.Context, roomID string, data any) (resp any,
func summaries(songs []port.Song) []protocol.TrackSummary {
items := make([]protocol.TrackSummary, len(songs))
for i, s := range songs {
items[i] = protocol.TrackSummary{Track: trackDTO(s.Track), Meta: meta(s.Meta)}
items[i] = protocol.TrackSummary{Track: protocol.Track{Source: protocol.Source(s.Track.Source), ID: s.Track.ID}, Meta: meta(s.Meta)}
}
return items
}
-4
View File
@@ -17,10 +17,6 @@ func (noTracks) PreparedTrack(context.Context, track.Track) (port.PreparedTrack,
return port.PreparedTrack{}, false, nil
}
func (noTracks) TrackMetas(context.Context, []track.Track) (map[track.Track]track.Meta, error) {
return nil, nil
}
func (noTracks) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
return nil
}
-42
View File
@@ -1,42 +0,0 @@
package api
import (
"net/http"
"net/netip"
"strings"
)
// 只有对端是回环、私有网段或链路本地地址的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理,
// 第一个不受信任的地址就是客户端。如果全都受信任,就取最左端的地址。受信任的代理只会写入合法的地址,
// 所以遇到不合法的值,说明已经到了客户端自己填写的部分,这时改用对端地址。
func clientIP(r *http.Request) string {
peer, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
addr := peer.Addr().Unmap()
if !trustedProxy(addr) {
return addr.String()
}
var hops []string
for _, v := range r.Header.Values("X-Forwarded-For") {
for hop := range strings.SplitSeq(v, ",") {
hops = append(hops, strings.TrimSpace(hop))
}
}
for i := len(hops) - 1; i >= 0; i-- {
hop, err := netip.ParseAddr(hops[i])
if err != nil {
break
}
hop = hop.Unmap()
if i == 0 || !trustedProxy(hop) {
return hop.String()
}
}
return addr.String()
}
func trustedProxy(addr netip.Addr) bool {
return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()
}
-41
View File
@@ -1,41 +0,0 @@
package api
import (
"net/http/httptest"
"testing"
)
func TestClientIP(t *testing.T) {
cases := []struct {
name string
remote string
forwarded []string
want string
}{
{"公网对端不看 X-Forwarded-For", "203.0.113.7:4321", []string{"198.51.100.1"}, "203.0.113.7"},
{"私有网段的代理转发时取公网客户端地址", "10.42.0.153:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"回环对端同样可信", "127.0.0.1:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"IPv6 回环对端同样可信", "[::1]:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"链路本地对端同样可信", "[fe80::1]:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"链式代理跳过右端的私有地址", "127.0.0.1:4321", []string{"203.0.113.7, 10.42.0.1, 192.168.1.2"}, "203.0.113.7"},
{"客户端自己填在左边的地址不采用", "10.42.0.153:4321", []string{"198.51.100.1, 203.0.113.7"}, "203.0.113.7"},
{"多个头按顺序拼接", "10.42.0.153:4321", []string{"198.51.100.1", "203.0.113.7"}, "203.0.113.7"},
{"全部是私有地址时取最左端", "10.42.0.153:4321", []string{"192.168.1.5, 10.42.0.1"}, "192.168.1.5"},
{"没有 X-Forwarded-For 时取对端", "10.42.0.153:4321", nil, "10.42.0.153"},
{"遇到不合法的地址就停下,取对端", "10.42.0.153:4321", []string{"203.0.113.7, unknown, 10.42.0.1"}, "10.42.0.153"},
{"IPv4 映射的 IPv6 对端按 IPv4 处理", "[::ffff:10.42.0.153]:4321", []string{"::ffff:203.0.113.7"}, "203.0.113.7"},
{"IPv4 映射的 IPv6 私有跳按 IPv4 处理", "10.42.0.153:4321", []string{"203.0.113.7, ::ffff:10.42.0.1"}, "203.0.113.7"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = tc.remote
for _, v := range tc.forwarded {
r.Header.Add("X-Forwarded-For", v)
}
if got := clientIP(r); got != tc.want {
t.Errorf("clientIP = %q,期望 %q", got, tc.want)
}
})
}
}
+12 -32
View File
@@ -24,7 +24,7 @@ var roomErrorCodes = map[room.ErrorKind]protocol.ErrorCode{
room.NothingPlaying: protocol.ErrorCodeNothingPlaying,
}
// bot 表示 by 是常驻房间的 bot 成员。返回的成员是房间命令的发起人。如果 bot 代填了点歌人,返回的成员 ID 不变,名字换成点歌人的名字加上“(bot)”。
// bot 表示 by 是常驻房间的 bot 成员。返回的成员是房间命令的发起人,bot 代填了点歌人时换成那个名字。
func (s *server) action(ctx context.Context, roomID string, by member.Member, bot bool, data any) (room.Action, member.Member, error) {
switch d := data.(type) {
case protocol.RoomSyncRequest:
@@ -48,7 +48,11 @@ func (s *server) action(ctx context.Context, roomID string, by member.Member, bo
// 协议解码保证要么只给出 track,要么只给出 source 和 keyword。
func pick(d protocol.QueueAddRequest) realtime.Pick {
if d.Track != nil {
return realtime.Pick{Track: trackOf(*d.Track), Requester: deref(d.Requester)}
t := track.Track{Source: track.Source(d.Track.Source), ID: d.Track.ID}
if t.Source == track.Link {
t.ID = track.LinkID(t.ID)
}
return realtime.Pick{Track: t, Requester: deref(d.Requester)}
}
return realtime.Pick{Source: track.Source(*d.Source), Keyword: *d.Keyword, Requester: deref(d.Requester)}
}
@@ -141,42 +145,18 @@ func (s *server) mediaURL(t track.Track) string {
return "/media/" + string(t.Source) + "/" + url.PathEscape(t.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 {
t.ID = track.LinkID(t.ID)
}
return t
}
// 封面存在对象存储里的曲目,对外的封面地址用 StorageURL 拼出。
func (s *server) trackMeta(m track.Meta) protocol.TrackMeta {
d := meta(m)
d.CoverURL = s.coverURL(m.CoverURL, m.CoverKey)
return d
}
func (s *server) coverURL(url string, key track.ObjectKey) string {
if key != "" {
return s.StorageURL + "/" + string(key)
}
return url
}
func trackDTO(t track.Track) protocol.Track {
return protocol.Track{Source: protocol.Source(t.Source), ID: t.ID}
}
func (s *server) entry(e room.Entry) protocol.Entry {
d := protocol.Entry{
ID: strconv.FormatUint(uint64(e.ID), 10),
Track: trackDTO(e.Track),
Meta: s.trackMeta(e.Meta),
Track: protocol.Track{Source: protocol.Source(e.Track.Source), ID: e.Track.ID},
Meta: meta(e.Meta),
Auto: e.Auto,
Likes: int64(e.Likes),
Status: protocol.EntryStatusPreparing,
}
if e.Meta.CoverKey != "" {
d.Meta.CoverURL = s.StorageURL + "/" + string(e.Meta.CoverKey)
}
if !e.Auto {
by := memberDTO(e.By)
d.By = &by
@@ -238,7 +218,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
+13 -11
View File
@@ -2,6 +2,7 @@ package api
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
@@ -9,24 +10,23 @@ import (
"strconv"
"time"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/protocol"
)
const (
// 心跳间隔小于入口的读超时,因为 nginx 默认 60 秒收不到数据就断开。
// 小于入口的读超时:nginx 默认 60 秒收不到数据就断开。
streamHeartbeat = 25 * time.Second
// 客户端不再读取时,写入在这个期限之后失败,事件流随之结束。否则关闭事件流(包括退出时)要一直等着阻塞的写入。
// 客户端不再读取时,写入在这个期限之后失败,事件流随之结束:否则关闭事件流(包括退出时)要一直等阻塞的写入。
streamWriteTimeout = 10 * time.Second
)
var streamCloses = map[realtime.CloseReason]streamClose{
realtime.CloseRestart: {Code: protocol.CloseServiceRestart, Message: "服务端实例退出或订阅中断,请重新连接"},
realtime.CloseRevoked: {Code: protocol.CloseUnauthorized, Message: "凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"},
realtime.CloseRevoked: {Code: protocol.CloseUnauthorized, Message: "凭证已失效:房间设置或修改了密码,或者 API Key 已重置"},
realtime.CloseRoomGone: {Code: protocol.CloseNotFound, Message: "房间已关闭或被删除"},
realtime.CloseOverflow: {Code: protocol.CloseSendQueueFull, Message: "积压的事件过多,请重新连接"},
// 不用 1012,因为共用一把 Key 的两个 bot 收到 4409 就该停下,而不是重连之后互相顶掉。
// 不是 1012:共用一把 Key 的两个 bot 看到它就该停下,而不是重连之后互相顶掉。
realtime.CloseReplaced: {Code: 4409, Message: "同一凭证打开了新的事件流"},
}
@@ -35,13 +35,13 @@ 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 的握手相同,打开之后的推送不设期限。
setup, cancel := context.WithTimeout(r.Context(), helloTimeout)
ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, maxBodyBytes, s.observe)
ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, s.observe)
cancel()
if err != nil {
s.fail(ctx, w, r, err)
@@ -81,7 +81,9 @@ func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
// 确认票据失败或 panic 时都要退订,否则事件流的名额和房间频道一直被占着。
func (s *server) observe(ctx context.Context, c call) (any, error) {
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.memberSession, c.bot, string(identity.TicketCredential(c.ticket)))
// 内存中只留凭证的哈希。
credential := sha256.Sum256([]byte(c.ticket))
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.bot, string(credential[:]))
if err != nil {
return nil, err
}
@@ -91,7 +93,7 @@ func (s *server) observe(ctx context.Context, c call) (any, error) {
s.Gateway.Unobserve(o)
}
}()
// 作废票据时先删除票据,再通知断开。如果通知发出时事件流还没在本实例生效,事件流就收不到,所以生效之后再确认一次票据。
// 作废时先删除票据、再通知断开:事件流在本实例生效之前发出的通知它收不到,所以生效之后再确认一次票据。
if _, err := s.Identity.VerifyTicket(ctx, c.ticket); err != nil {
return nil, err
}
@@ -116,7 +118,7 @@ type eventStream struct {
logger *slog.Logger
}
// data 只占一行,因为 json.Marshal 的输出里没有换行。
// data 只占一行:json.Marshal 的输出中没有换行。
func (st eventStream) event(ctx context.Context, name, id string, data any) bool {
payload, err := json.Marshal(data)
if err != nil {
+4 -38
View File
@@ -145,45 +145,11 @@ func TestRoomEvents(t *testing.T) {
if _, err := f.admin.ResetRoomAPIKey(t.Context(), hall); err != nil {
t.Fatal(err)
}
checkEvent(t, r, "event: close", `data: {"code":4401,"message":"凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"}`)
checkEvent(t, r, "event: close", `data: {"code":4401,"message":"凭证已失效:房间设置或修改了密码,或者 API Key 已重置"}`)
checkEnded(t, r)
})
}
// 网关定期到存储里核对连接所用的凭证。事件流登记的凭证要和存储里的票据哈希相同,否则仍然有效的事件流也会被断开。
func TestRoomEventsSurviveCredentialCheck(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
f := newWSFixture(t, store, store, store)
hall, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "大厅"})
if err != nil {
t.Fatal(err)
}
key, err := f.admin.ResetRoomAPIKey(t.Context(), hall)
if err != nil {
t.Fatal(err)
}
_, _, ticket := f.enter(t, hall)
bot, member := f.stream(t, key), f.stream(t, ticket)
readEvent(t, bot)
readEvent(t, member)
// 心跳每 25 秒一次,核对每 30 秒一次,所以第二次心跳之前已经核对过一次。
for range 2 {
checkEvent(t, bot, ": ping")
checkEvent(t, member, ": ping")
}
// 不经过 admin 重置,就没有断开的通知,只能靠下一次核对断开。
_, fresh := identity.NewAPIKey()
if err := store.ResetRoomAPIKey(t.Context(), hall, fresh); err != nil {
t.Fatal(err)
}
checkEvent(t, bot, "event: close", `data: {"code":4401,"message":"凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"}`)
checkEnded(t, bot)
checkEvent(t, member, ": ping")
})
}
func TestRoomEventsCloseWithTheRoom(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
@@ -298,7 +264,7 @@ func TestRoomEventsOpeningTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
f := newWSFixtureWithTickets(t, store, stallingTickets{store}, store, store)
// 客户端到期限之后也放弃,这样服务端没有设期限时,测试不会一直等下去。
// 期限之后客户端也放弃:服务端没有期限时,这里不会一直等下去。
ctx, cancel := context.WithTimeout(t.Context(), 2*helloTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://alisten.test/api/room/events", nil)
@@ -322,7 +288,7 @@ func TestRoomEventsOpeningTimesOut(t *testing.T) {
})
}
// 第二次查找票据时发生 panic,也就是事件流生效之后的那次确认。
// 第二次查找票据,即事件流生效之后的确认,发生 panic。
type panickingRecheck struct {
*porttest.Store
lookups atomic.Int32
@@ -369,7 +335,7 @@ func TestRoomEventsEndWhenClientStopsReading(t *testing.T) {
}
_, m, ticket := f.enter(t, hall)
f.stream(t, ticket)
// 内存连接上的写入要等对方读取,所以客户端不读,这个事件就一直写不出去。
// 内存连接上的写入要等对方读取:客户端不读,这个事件就一直写不出去。
if _, err := f.gateway.Do(t.Context(), hall, m, room.Chat{Text: "你好"}); err != nil {
t.Fatal(err)
}
-140
View File
@@ -1,140 +0,0 @@
package api
import (
"context"
"errors"
"net/http"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/protocol"
)
// 用户在 GitHub 上授权之后,浏览器跳到这个路径。网页的 origin 加上 GitHubCallbackPath,就是 OAuth App 里登记的回调地址。
const GitHubCallbackPath = "/api/auth/github/callback"
// 回调处理完之后的落地页,结果放在片段里。App 用系统浏览器授权,要跳到 App 的自定义 scheme 才能回到 App。
// 自定义 scheme 可能被别的 App 抢注,但截获的兑换码换不到身份。兑换码 1 分钟内有效,只能用一次。兑换时要带上发起授权的客户端在本机保存的
// nonce,而且必须由发起授权的同一个成员调用。
const (
githubWebLanding = "/auth/github"
githubAppLanding = "moe.dihe.alisten:/auth/github"
)
func (s *server) startGithubLogin(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeStartGithubLoginRequest(c.body)
if err != nil {
return nil, err
}
intent := port.GitHubLogin
switch req.Intent {
case protocol.GitHubIntentBind:
intent = port.GitHubBind
case protocol.GitHubIntentVerify:
intent = port.GitHubVerify
}
client := port.GitHubWeb
if req.Client != nil && *req.Client == protocol.GitHubClientApp {
client = port.GitHubApp
}
url, err := s.GitHub.Authorize(ctx, c.member.ID, c.clientIP, intent, client, req.Nonce, deref(req.CurrentPassword))
if err != nil {
return nil, err
}
return protocol.StartGithubLoginResponse{URL: url}, nil
}
func (s *server) finishGithubLogin(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeFinishGithubLoginRequest(c.body)
if err != nil {
return nil, err
}
result, err := s.GitHub.Exchange(ctx, c.member, bearer(c.r), req.Code, req.Nonce)
if err != nil {
return nil, err
}
resp := protocol.FinishGithubLoginResponse{Member: memberDTO(result.Member), Account: accountDTO(result.Account)}
if result.Session != nil {
resp.MemberToken = &result.Session.MemberToken
}
return resp, nil
}
func (s *server) unbindGithub(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeUnbindGithubRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.GitHub.Unbind(ctx, c.member.ID, req.CurrentPassword)
}
func (s *server) adminGetGithubCredential(ctx context.Context, _ call) (any, error) {
clientID, err := s.GitHub.ClientID(ctx)
if err != nil {
return nil, err
}
return protocol.AdminGetGithubCredentialResponse{ClientID: optional(clientID), ClientSecretSet: clientID != "", CallbackURL: s.PublicURL + GitHubCallbackPath}, nil
}
func (s *server) adminSetGithubCredential(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminSetGithubCredentialRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.GitHub.SetOAuthApp(ctx, port.GitHubOAuthApp{ClientID: req.ClientID, ClientSecret: req.ClientSecret})
}
func (s *server) adminDeleteGithubCredential(ctx context.Context, _ call) (any, error) {
return nil, s.GitHub.DeleteOAuthApp(ctx)
}
// 浏览器从 GitHub 跳回来,换到兑换码之后跳到发起授权的客户端的落地页。结果放在片段里,不会出现在之后的请求和 Referer 里。
// 回调地址的查询串里有授权码,所以响应也不让浏览器把回调地址当作 Referer 发出去。跳转目标只看 state 对应的记录,不看回调地址里的参数,
// 否则就成了开放重定向。
func (s *server) githubCallback(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
defer cancel()
q := r.URL.Query()
var (
client port.GitHubClient
fragment string
err error
)
if reason := q.Get("error"); reason != "" {
fragment = "error=denied"
if reason != "access_denied" {
s.logger.WarnContext(ctx, "GitHub 拒绝了授权请求", "reason", reason, "description", q.Get("error_description"))
fragment = "error=failed"
}
client, err = s.GitHub.Cancel(ctx, q.Get("state"))
if err != nil && !errors.Is(err, identity.ErrGitHubRequestExpired) {
s.logger.ErrorContext(ctx, "作废 GitHub 授权的 state 失败", "error", err)
}
} else {
var code string
client, code, err = s.GitHub.Callback(ctx, q.Get("state"), q.Get("code"))
switch {
case err == nil:
fragment = "code=" + code
case errors.Is(err, identity.ErrGitHubRequestExpired):
fragment = "error=expired"
case errors.Is(err, identity.ErrGitHubDisabled):
fragment = "error=failed"
case errors.Is(err, identity.ErrGitHubFailed):
s.logger.WarnContext(ctx, "没有从 GitHub 换到用户", "error", err)
fragment = "error=failed"
default:
s.logger.ErrorContext(ctx, "处理 GitHub 的回调失败", "error", err)
fragment = "error=failed"
}
}
landing := githubWebLanding
if client == port.GitHubApp {
landing = githubAppLanding
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
// 不用 http.Redirect,因为 http.Redirect 会把片段当作路径的一部分清理掉。
w.Header().Set("Location", landing+"#"+fragment)
w.WriteHeader(http.StatusFound)
}
+6 -19
View File
@@ -13,12 +13,11 @@ import (
"github.com/dihemoe/alisten/protocol"
)
// 路径中的房间 ID、别名和用户名不经过协议解码。格式不正确的房间 ID、别名或用户名一定不存在,其中含有 U+0000 等字符的还会让
// 路径中的房间 ID 和别名不经过协议解码。格式不正确的房间 ID 或别名一定不存在,其中含有 U+0000 等字符的还会让
// PostgreSQL 报错,所以直接按不存在处理。
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}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
usernamePattern = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
)
func (s *server) createMember(ctx context.Context, c call) (any, error) {
@@ -32,20 +31,8 @@ func (s *server) createMember(ctx context.Context, c call) (any, error) {
return protocol.CreateMemberResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member)}, nil
}
func (s *server) getMe(ctx context.Context, c call) (any, error) {
resp := protocol.GetMeResponse{Member: memberDTO(c.member)}
account, registered, err := s.Accounts.Account(ctx, c.member.ID)
if err != nil {
return nil, err
}
if registered {
dto := accountDTO(account)
resp.Account = &dto
}
if resp.RecordListens, err = s.Listens.Recording(ctx, c.member.ID); err != nil {
return nil, err
}
return resp, nil
func (s *server) getMe(_ context.Context, c call) (any, error) {
return protocol.GetMeResponse{Member: memberDTO(c.member)}, nil
}
func (s *server) updateMe(ctx context.Context, c call) (any, error) {
@@ -101,7 +88,7 @@ func (s *server) createTicket(ctx context.Context, c call) (any, error) {
if err != nil {
return nil, err
}
ticket, err := s.Identity.IssueTicket(ctx, c.member.ID, bearer(c.r), id, deref(req.Password))
ticket, err := s.Identity.IssueTicket(ctx, c.member.ID, id, deref(req.Password))
if err != nil {
return nil, err
}
-42
View File
@@ -1,42 +0,0 @@
package api
import (
"context"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) listMyListens(ctx context.Context, c call) (any, error) {
listens, err := s.Listens.List(ctx, c.member.ID)
if err != nil {
return nil, err
}
items := make([]protocol.MyListen, len(listens))
for i, l := range listens {
items[i] = protocol.MyListen{Track: trackDTO(l.Track), Meta: s.trackMeta(l.Meta), HeardAtMs: l.HeardAt.UnixMilli()}
}
return protocol.ListMyListensResponse{Items: items}, nil
}
func (s *server) removeMyListen(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRemoveMyListenRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Listens.Remove(ctx, c.member.ID, trackOf(req.Track))
}
func (s *server) clearMyListens(ctx context.Context, c call) (any, error) {
return nil, s.Listens.Clear(ctx, c.member.ID)
}
func (s *server) enableListenRecording(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeEnableListenRecordingRequest(c.body); err != nil {
return nil, err
}
return nil, s.Listens.SetRecording(ctx, c.member.ID, true)
}
func (s *server) disableListenRecording(ctx context.Context, c call) (any, error) {
return nil, s.Listens.SetRecording(ctx, c.member.ID, false)
}
+15 -49
View File
@@ -3,9 +3,10 @@ package api
import (
"context"
"net/http"
"regexp"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/domain/track"
"github.com/dihemoe/alisten/protocol"
)
@@ -42,68 +43,33 @@ func (s *server) redirectTarget(ctx context.Context, r *http.Request, t track.Tr
return s.Playback.URL(ctx, t, time.Now())
}
var mediaSorts = map[protocol.MediaSort]port.MediaSort{
protocol.MediaSortStored: port.MediaByStored,
protocol.MediaSortIdle: port.MediaByIdle,
protocol.MediaSortSize: port.MediaBySize,
}
var mediaKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
func (s *server) adminListMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminListMediaRequest(c.body)
func (s *server) adminListMedia(ctx context.Context, _ call) (any, error) {
list, err := s.Media.List(ctx)
if err != nil {
return nil, err
}
q := port.MediaQuery{Page: 1, Sort: port.MediaByStored}
if req.Page != nil {
q.Page = int(*req.Page)
}
if req.Sort != nil {
q.Sort = mediaSorts[*req.Sort]
}
if req.Q != nil {
q.Title = *req.Q
}
page, err := s.Media.List(ctx, q)
if err != nil {
return nil, err
}
items := make([]protocol.StoredMedia, len(page.Items))
for i, m := range page.Items {
items := make([]protocol.StoredMedia, len(list))
for i, m := range list {
link := m.Link
// 后台把 link 当作链接打开,所以 B 站的歌给出视频页面。
// 后台把 link 当作链接打开,B 站的歌给出视频页面。
if m.Source == track.Bilibili {
link = track.BilibiliPageURL(m.Link)
}
items[i] = protocol.StoredMedia{
Key: m.Key,
Source: protocol.Source(m.Source),
Link: link,
Title: m.Title,
Artist: m.Artist,
CoverURL: s.coverURL(m.CoverURL, m.CoverKey),
Size: m.Size,
StoredAtMs: m.StoredAt.UnixMilli(),
}
items[i] = protocol.StoredMedia{Key: m.Key, Source: protocol.Source(m.Source), Link: link, Title: m.Title, Size: m.Size, StoredAtMs: m.StoredAt.UnixMilli()}
if m.LastPlayedAt != nil {
played := m.LastPlayedAt.UnixMilli()
items[i].LastPlayedAtMs = &played
}
}
return protocol.AdminListMediaResponse{Items: items, Total: int64(page.Total), TotalBytes: page.TotalBytes, Page: int64(q.Page), PageSize: port.MediaPageSize}, nil
return protocol.AdminListMediaResponse{Items: items}, nil
}
func (s *server) adminRemoveMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminRemoveMediaRequest(c.body)
if err != nil {
return nil, err
func (s *server) adminDeleteMedia(ctx context.Context, c call) (any, error) {
key := c.r.PathValue("key")
if !mediaKeyPattern.MatchString(key) {
return nil, media.ErrNotFound
}
keys := make([]string, len(req.Media))
for i, m := range req.Media {
keys[i] = m.Key
}
deleted, inUse, err := s.Media.DeleteMany(ctx, keys)
if err != nil {
return nil, err
}
return protocol.AdminRemoveMediaResponse{Deleted: deleted, InUse: inUse}, nil
return nil, s.Media.Delete(ctx, key)
}
+44 -44
View File
@@ -24,63 +24,63 @@ import (
"github.com/dihemoe/alisten/protocol"
)
type storedMedia struct {
items []port.StoredMedia
used bool
}
func (m *storedMedia) ListStoredMedia(context.Context) ([]port.StoredMedia, error) {
return m.items, nil
}
func (m *storedMedia) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
for _, item := range m.items {
if item.Key == key {
return item, true, nil
}
}
return port.StoredMedia{}, false, nil
}
func (m *storedMedia) DeleteStoredMedia(context.Context, string) error {
m.items = nil
return nil
}
func (m *storedMedia) TrackInUse(context.Context, track.Track) (bool, error) {
return m.used, nil
}
func TestAdminMedia(t *testing.T) {
key := media.Key("https://example.com/a.mp3")
other := media.Key("https://example.com/b.mp3")
video := media.Key("BV1xx411c7mD/2")
played := time.UnixMilli(1760000000456)
library := porttest.NewMediaLibrary(
port.StoredMedia{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Artist: "周杰伦", Size: 12290, CoverKey: track.ObjectKey("link/" + key + "/cover.jpg"), StoredAt: time.UnixMilli(1760000000123)},
port.StoredMedia{Key: other, Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, CoverURL: "https://example.com/b.jpg", StoredAt: time.UnixMilli(1760000000789), LastPlayedAt: &played},
port.StoredMedia{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Artist: "某个 UP 主", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
)
stored := &storedMedia{items: []port.StoredMedia{
{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Size: 12290, StoredAt: time.UnixMilli(1760000000123), LastPlayedAt: &played},
{Key: media.Key("https://example.com/b.mp3"), Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, StoredAt: time.UnixMilli(1760000000789)},
{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
}}
store := porttest.New()
s := services(store, store, "admin-secret")
s.StorageURL = "https://alisten.example/storage/alisten"
s.Media = media.NewLibrary(library, inRooms{{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}}, porttest.NewMediaStore())
s.Media = media.NewLibrary(stored, stored, porttest.NewMediaStore())
server, _ := start(t, s)
admin := []string{"Authorization", "Bearer admin-secret"}
want := response{status: http.StatusOK, body: `{"items":[` +
`{"artist":"某个 UP 主","coverUrl":"","key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"},` +
`{"artist":"","coverUrl":"https://example.com/b.jpg","key":"` + other + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
`{"artist":"周杰伦","coverUrl":"https://alisten.example/storage/alisten/link/` + key + `/cover.jpg","key":"` + key + `","lastPlayedAtMs":null,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"}],` +
`"page":1,"pageSize":50,"total":3,"totalBytes":12293}`}
if got := send(t, server, http.MethodPost, "/api/admin/media/queries", "{}", admin...); got != want {
t.Errorf("按默认参数列出已存储的媒体 = %#v\n期望 %#v", got, want)
}
for _, tt := range []struct {
body string
page int64
want []string
total, size int64
}{
{`{"sort": "stored"}`, 1, []string{video, other, key}, 3, 12293},
{`{"sort": "idle"}`, 1, []string{video, key, other}, 3, 12293},
{`{"sort": "size", "q": "晴天"}`, 1, []string{key, video}, 2, 12292},
{`{"page": 2}`, 2, []string{}, 3, 12293},
} {
got := send(t, server, http.MethodPost, "/api/admin/media/queries", tt.body, admin...)
var page protocol.AdminListMediaResponse
if err := json.Unmarshal([]byte(got.body), &page); err != nil {
t.Fatalf("%s = %#v", tt.body, got)
}
keys := make([]string, len(page.Items))
for i, m := range page.Items {
keys[i] = m.Key
}
if got.status != http.StatusOK || !slices.Equal(keys, tt.want) || page.Page != tt.page || page.Total != tt.total || page.TotalBytes != tt.size {
t.Errorf("%s = %#v,期望第 %d 页 %q,共 %d 首 %d 字节", tt.body, got, tt.page, tt.want, tt.total, tt.size)
}
`{"key":"` + key + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"},` +
`{"key":"` + media.Key("https://example.com/b.mp3") + `","lastPlayedAtMs":null,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
`{"key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"}]}`}
if got := send(t, server, http.MethodGet, "/api/admin/media", "", admin...); got != want {
t.Errorf("列出已存储的媒体 = %#v\n期望 %#v", got, want)
}
if got := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+key+`"}, {"key": "not-a-key"}]}`, admin...); got.status != http.StatusBadRequest {
t.Errorf("批量删除里有格式不对的 key = %#v,期望 400、一首也不删", got)
}
if got, want := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+video+`"}, {"key": "`+key+`"}, {"key": "`+media.Key("https://example.com/missing.mp3")+`"}]}`, admin...),
(response{status: http.StatusOK, body: `{"deleted":["` + key + `"],"inUse":["` + video + `"]}`}); got != want {
t.Errorf("批量删除 = %#v,期望 %#v", got, want)
stored.used = true
if got, want := send(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...), failure(protocol.ErrorCodeConflict, "这首歌还在房间的播放列表中,不能删除"); got != want {
t.Errorf("删除仍被房间引用的媒体 = %#v,期望 %#v", got, want)
}
stored.used = false
resp, header := exchange(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...)
checkNoContent(t, "删除媒体", resp, header)
}
type playURL map[string]string
-48
View File
@@ -1,48 +0,0 @@
package api
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"testing"
"time"
"rsc.io/qr"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/protocol"
)
type unlimited struct{}
func (unlimited) Take(context.Context, string, int, time.Duration) (time.Duration, error) {
return 0, nil
}
// 二维码的内容是网页上兑换配对码的地址,配对码放在片段里。
func TestPairingCodeQRImage(t *testing.T) {
store := porttest.New()
s := services(store, store, "")
s.Accounts = identity.NewAccounts(store, store, unlimited{}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), s.Gateway)
s.PublicURL = "https://alisten.example"
server, _ := start(t, s)
token := memberToken(t, server)
auth := []string{"Authorization", "Bearer " + token}
if got := send(t, server, http.MethodPost, "/api/account", `{"username":"alice","password":"correct horse"}`, auth...); got.status != http.StatusCreated {
t.Fatalf("注册 = %#v", got)
}
got := send(t, server, http.MethodPost, "/api/account/pairing-codes", "{}", auth...)
var created protocol.CreatePairingCodeResponse
if err := json.Unmarshal([]byte(got.body), &created); err != nil || got.status != http.StatusCreated {
t.Fatalf("生成配对码 = %#v", got)
}
code, err := qr.Encode("https://alisten.example/pair#"+created.Code, qr.M)
if err != nil {
t.Fatal(err)
}
if want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()); created.QrImage != want {
t.Errorf("二维码 = %.60q…,期望内容为 https://alisten.example/pair#%s 的 PNG", created.QrImage, created.Code)
}
}
-242
View File
@@ -1,242 +0,0 @@
package api
import (
"context"
"time"
"github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) playlistDTO(p port.PlaylistRecord) protocol.MyPlaylist {
var cover *string
if p.Cover != nil {
url := s.coverURL(p.Cover.URL, p.Cover.Key)
cover = &url
}
return protocol.MyPlaylist{
ID: p.ID,
Name: p.Name,
Favorites: p.Favorites,
TrackCount: int64(p.TrackCount),
CreatedAtMs: p.CreatedAt.UnixMilli(),
UpdatedAtMs: p.UpdatedAt.UnixMilli(),
CoverURL: cover,
ShareID: optional(p.ShareID),
}
}
func (s *server) playlistItemDTO(item port.PlaylistItem) protocol.MyPlaylistItem {
return protocol.MyPlaylistItem{
Track: trackDTO(item.Track),
Meta: s.trackMeta(item.Meta),
}
}
func (s *server) listMyPlaylists(ctx context.Context, c call) (any, error) {
lists, err := s.Playlists.List(ctx, c.member.ID)
if err != nil {
return nil, err
}
items := make([]protocol.MyPlaylist, len(lists))
for i, p := range lists {
items[i] = s.playlistDTO(p)
}
return protocol.ListMyPlaylistsResponse{Items: items}, nil
}
func (s *server) createMyPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeCreateMyPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, err := s.Playlists.Create(ctx, c.member.ID, req.Name)
if err != nil {
return nil, err
}
return s.playlistDTO(p), nil
}
func (s *server) renameMyPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRenameMyPlaylistRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Playlists.Rename(ctx, c.member.ID, c.r.PathValue("id"), req.Name)
}
func (s *server) deleteMyPlaylist(ctx context.Context, c call) (any, error) {
return nil, s.Playlists.Delete(ctx, c.member.ID, c.r.PathValue("id"))
}
func (s *server) listMyPlaylistItems(ctx context.Context, c call) (any, error) {
list, err := s.Playlists.Items(ctx, c.member.ID, c.r.PathValue("id"))
if err != nil {
return nil, err
}
items := make([]protocol.MyPlaylistItem, len(list))
for i, item := range list {
items[i] = s.playlistItemDTO(item)
}
return protocol.ListMyPlaylistItemsResponse{Items: items}, nil
}
func (s *server) addMyPlaylistItem(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAddMyPlaylistItemRequest(c.body)
if err != nil {
return nil, err
}
item, err := s.Playlists.Add(ctx, c.member.ID, c.r.PathValue("id"), trackOf(req.Track))
if err != nil {
return nil, err
}
return protocol.AddMyPlaylistItemResponse{Item: s.playlistItemDTO(item)}, nil
}
func tracksOf(in []protocol.Track) []track.Track {
out := make([]track.Track, len(in))
for i, t := range in {
out[i] = trackOf(t)
}
return out
}
func (s *server) removeMyPlaylistItems(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRemoveMyPlaylistItemsRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Playlists.Remove(ctx, c.member.ID, c.r.PathValue("id"), tracksOf(req.Tracks))
}
func (s *server) listMyTrackPlaylists(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeListMyTrackPlaylistsRequest(c.body)
if err != nil {
return nil, err
}
ids, err := s.Playlists.Containing(ctx, c.member.ID, trackOf(req.Track))
if err != nil {
return nil, err
}
return protocol.ListMyTrackPlaylistsResponse{PlaylistIds: ids}, nil
}
func (s *server) copyMyPlaylistItems(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeCopyMyPlaylistItemsRequest(c.body)
if err != nil {
return nil, err
}
n, err := s.Playlists.Copy(ctx, c.member.ID, req.From, c.r.PathValue("id"), tracksOf(req.Tracks))
if err != nil {
return nil, err
}
return protocol.CopyMyPlaylistItemsResponse{Added: int64(n)}, nil
}
func (s *server) moveMyPlaylistItems(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeMoveMyPlaylistItemsRequest(c.body)
if err != nil {
return nil, err
}
var before *track.Track
if req.Before != nil {
t := trackOf(*req.Before)
before = &t
}
return nil, s.Playlists.Move(ctx, c.member.ID, c.r.PathValue("id"), tracksOf(req.Tracks), before)
}
func (s *server) importMyPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeImportMyPlaylistRequest(c.body)
if err != nil {
return nil, err
}
tracks := make([]playlist.ImportedTrack, len(req.Tracks))
for i, t := range req.Tracks {
tracks[i] = playlist.ImportedTrack{
Track: trackOf(t.Track),
Title: t.Title,
Artist: deref(t.Artist),
Album: deref(t.Album),
CoverURL: deref(t.CoverURL),
PageURL: deref(t.PageURL),
Duration: time.Duration(t.DurationMs) * time.Millisecond,
}
}
p, n, err := s.Playlists.ImportFile(ctx, c.member.ID, req.Name, tracks)
if err != nil {
return nil, err
}
return protocol.ImportMyPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil
}
func (s *server) resolveMyPlaylistLink(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeResolveMyPlaylistLinkRequest(c.body)
if err != nil {
return nil, err
}
source, id, err := s.Playlists.ResolveLink(ctx, c.member.ID, req.Link)
if err != nil {
return nil, err
}
return protocol.ResolveMyPlaylistLinkResponse{Source: protocol.Source(source), PlaylistID: id}, nil
}
func (s *server) importMyPlatformPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeImportMyPlatformPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, n, err := s.Playlists.ImportPlatform(ctx, c.member.ID, req.Name, track.Source(req.Source), req.PlaylistID)
if err != nil {
return nil, err
}
return protocol.ImportMyPlatformPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil
}
func (s *server) shareMyPlaylist(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeShareMyPlaylistRequest(c.body); err != nil {
return nil, err
}
shareID, err := s.Playlists.Share(ctx, c.member.ID, c.r.PathValue("id"))
if err != nil {
return nil, err
}
return protocol.ShareMyPlaylistResponse{ShareID: shareID}, nil
}
func (s *server) unshareMyPlaylist(ctx context.Context, c call) (any, error) {
return nil, s.Playlists.Unshare(ctx, c.member.ID, c.r.PathValue("id"))
}
func (s *server) getSharedPlaylist(ctx context.Context, c call) (any, error) {
p, err := s.Playlists.Shared(ctx, c.r.PathValue("shareId"))
if err != nil {
return nil, err
}
items := make([]protocol.MyPlaylistItem, len(p.Items))
for i, item := range p.Items {
items[i] = s.playlistItemDTO(item)
}
return protocol.SharedPlaylist{
Name: p.Name,
Favorites: p.Favorites,
OwnerName: p.OwnerName,
OwnerAvatarHash: p.OwnerAvatarHash,
Items: items,
}, nil
}
func (s *server) copySharedPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeCopySharedPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, err := s.Playlists.CopyShared(ctx, c.member.ID, c.r.PathValue("shareId"), req.Name)
if err != nil {
return nil, err
}
return s.playlistDTO(p), nil
}
+3 -10
View File
@@ -87,22 +87,15 @@ func qrLogin(session port.QRLoginSession) (protocol.QRLogin, error) {
if session.LoginURL == "" {
return protocol.QRLogin{}, fmt.Errorf("音源没有给出二维码图片和登录链接:%w", port.ErrProviderFailed)
}
var err error
if image, err = qrDataURI(session.LoginURL); err != nil {
code, err := qr.Encode(session.LoginURL, qr.M)
if err != nil {
return protocol.QRLogin{}, fmt.Errorf("把登录链接编码成二维码:%w:%w", err, port.ErrProviderFailed)
}
image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())
}
return protocol.QRLogin{Key: session.Key, QrImage: image, ExpiresAtMs: session.ExpiresAt.UnixMilli()}, nil
}
func qrDataURI(text string) (string, error) {
code, err := qr.Encode(text, qr.M)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()), nil
}
func qrLoginResult(r port.QRLoginResult) protocol.QRLoginResult {
return protocol.QRLoginResult{Status: protocol.QRLoginStatus(r.Status), Message: r.Message}
}
+3 -3
View File
@@ -43,7 +43,7 @@ func newRoomFixture(t *testing.T) roomFixture {
store := porttest.New()
music := newCatalog(store, 10)
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
@@ -232,7 +232,7 @@ func TestRoomRequestOfDisconnectedClientIsNotCounted(t *testing.T) {
func TestRoomListingShowsOnlineCount(t *testing.T) {
f := newRoomFixture(t)
id, m, _ := f.enter(t)
session, err := f.gateway.Join(t.Context(), id, m, nil, nil)
session, err := f.gateway.Join(t.Context(), id, m)
if err != nil {
t.Fatal(err)
}
@@ -252,7 +252,7 @@ func TestProfileChangeReachesRooms(t *testing.T) {
session := decode[protocol.CreateMemberResponse](t, send(t, f.server, http.MethodPost, "/api/members", "{}"))
created := decode[protocol.CreateRoomResponse](t, send(t, f.server, http.MethodPost, "/api/rooms", `{"name":"临时","description":""}`))
m := member.Member{ID: session.Member.ID, Name: session.Member.Name}
s, err := f.gateway.Join(t.Context(), created.ID, m, nil, nil)
s, err := f.gateway.Join(t.Context(), created.ID, m)
if err != nil {
t.Fatal(err)
}
+2 -17
View File
@@ -7,15 +7,11 @@ import (
)
func (s *server) getSite(ctx context.Context, _ call) (any, error) {
settings, err := s.Site.Settings(ctx)
name, err := s.Site.BrandName(ctx)
if err != nil {
return nil, err
}
githubClientID, err := s.GitHub.ClientID(ctx)
if err != nil {
return nil, err
}
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen, GithubLogin: githubClientID != ""}, nil
return protocol.GetSiteResponse{BrandName: name}, nil
}
func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
@@ -29,14 +25,3 @@ func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
}
return protocol.AdminUpdateSiteResponse{BrandName: name}, nil
}
func (s *server) adminOpenRegistration(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeAdminOpenRegistrationRequest(c.body); err != nil {
return nil, err
}
return nil, s.Site.SetRegistrationOpen(ctx, true)
}
func (s *server) adminCloseRegistration(ctx context.Context, _ call) (any, error) {
return nil, s.Site.SetRegistrationOpen(ctx, false)
}
+3 -6
View File
@@ -15,17 +15,14 @@ func TestSite(t *testing.T) {
brandName := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `"}`}
}
site := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","githubLogin":false,"registrationOpen":true}`}
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("Alisten") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("Alisten") {
t.Errorf("没有设置过时 GET /api/site = %#v,期望默认的 Alisten", got)
}
if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":" 周末听歌会 "}`, adminHeader...); got != brandName("周末听歌会") {
t.Errorf("PATCH /api/admin/site = %#v,期望返回去掉首尾空白之后存下的名字", got)
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
t.Errorf("修改之后 GET /api/site = %#v,期望去掉首尾空白的新名字", got)
}
@@ -39,7 +36,7 @@ func TestSite(t *testing.T) {
if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":"别的名字"}`); got != unauthorized {
t.Errorf("不带管理 token 的 PATCH = %#v,期望 %#v", got, unauthorized)
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
t.Errorf("被拒绝的修改之后 GET /api/site = %#v,期望不变", got)
}
if logs.Len() != 0 {
+4 -4
View File
@@ -71,8 +71,8 @@ func (s *server) websocket(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 超时时先以 4408 关闭连接,再取消 ctx。如果先取消 ctx,正在读取第一帧的 Read 会直接断开连接,
// 客户端就收不到 4408。
// 超时时先以 4408 关闭连接,再取消 ctx:如果先取消,正在读取第一帧的 Read 会直接断开连接,
// 客户端收不到 4408。
timeout := time.AfterFunc(helloTimeout, func() {
_ = conn.Close(protocol.CloseHandshakeTimeout, "")
cancel()
@@ -197,7 +197,7 @@ func (s *server) perform(ctx context.Context, sess *realtime.Session, tag reques
}
return err
}
// 握手时已经拒绝了 API Key,所以会话的成员不是 bot,房间命令总是以会话的成员发出。
// 握手拒绝了 API Key,会话的成员不是 bot,房间命令总以会话的成员发出。
a, _, err := s.action(ctx, sess.Room(), sess.Member(), false, data)
if err != nil {
sess.Fail(tag, err)
@@ -216,7 +216,7 @@ func (s *server) write(ctx context.Context, conn *websocket.Conn, sess *realtime
_ = conn.Close(sessionCloseCodes[reason], "")
return
}
// 写入以会话的 ctx 为期限。这样客户端不再读取、发送队列随之溢出时,阻塞的写入会随会话关闭而中止。
// 写入以会话的 ctx 为期限:客户端不再读取、发送队列溢出时,阻塞的写入随会话关闭而中止。
if !s.writeMessage(sess.Context(), conn, s.message(m, helloID)) {
return
}
+5 -5
View File
@@ -36,7 +36,7 @@ import (
"github.com/dihemoe/alisten/protocol"
)
// 内存里的监听器。synctest 的时钟只在 bubble 内的全部 goroutine 都持久阻塞时才前进,而阻塞在真实网络连接上不算持久阻塞。
// 内存中的监听器:synctest 的时钟只在 bubble 内的全部 goroutine 都持久阻塞时前进,阻塞在真实网络连接上不算持久阻塞。
type pipeListener struct {
conns chan net.Conn
closed chan struct{}
@@ -118,7 +118,7 @@ type wsFixture struct {
listener *pipeListener
metrics *prometheus.Registry
spans *tracetest.SpanRecorder
// 这些场景都不应该产生日志。需要日志的用例读取 logs 之后要清空 logs。
// 这些场景都不应产生日志;需要日志的用例读取之后要清空它。
logs *bytes.Buffer
}
@@ -134,7 +134,7 @@ func newWSFixtureWithTickets(t *testing.T, store *porttest.Store, tickets port.T
music := newCatalog(directory, 10)
node := porttest.NewRedis().Node("a")
preparer := porttest.NewPreparer()
host, gateway := realtime.New(snapshots, store, store, store, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(snapshots, store, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
@@ -191,7 +191,7 @@ func (f *wsFixture) enter(t *testing.T, roomID string) (string, member.Member, s
t.Fatal(err)
}
}
ticket, err := f.identity.IssueTicket(t.Context(), session.Member.ID, session.MemberToken, roomID, "")
ticket, err := f.identity.IssueTicket(t.Context(), session.Member.ID, roomID, "")
if err != nil {
t.Fatal(err)
}
@@ -316,7 +316,7 @@ func TestHandshakeTimeoutCancelsLookups(t *testing.T) {
store := porttest.New()
directory := &stallingDirectory{Store: store, returned: make(chan struct{})}
f := newWSFixture(t, store, store, directory)
// 关闭连接时同步检查查询有没有被取消,这样结果不取决于 goroutine 的调度。
// 关闭连接时同步检查查询是否已被取消,结果不取决于 goroutine 的调度。
var cancelledFirst atomic.Bool
f.listener.onServerClose = func() {
if ctx := directory.lookup.Load(); ctx != nil && (*ctx).Err() != nil {
+5 -5
View File
@@ -25,8 +25,8 @@ const (
prepareQueue = "prepare"
prepareWorkers = 4
prepareTimeout = 15 * time.Minute
// 进程崩溃后停留在 running 状态的任务,最多过这么久就由 River 重新调度。River 默认至少要等 1 小时,
// 这期间这首歌一直处于准备中。
// 进程崩溃后停留在 running 状态的任务,最多这么久之后由 River 重新调度;River 默认至少要等 1 小时,
// 期间这首歌一直处于准备中。
rescueStuckJobs = 20 * time.Minute
)
@@ -37,7 +37,7 @@ const (
mediaWorkers = 1
)
// 适配器之间不能互相 import,所以 internal/adapter/out/queue 要按同样的 kind 和字段提交任务。
// 适配器之间不能互相 import,internal/adapter/out/queue 按同样的 kind 和字段提交任务。
type prepareArgs struct {
Source string `json:"source"`
ID string `json:"id"`
@@ -81,7 +81,7 @@ func newClient(pool *pgxpool.Pool, queues map[string]river.QueueConfig, run func
Workers: workers,
JobTimeout: prepareTimeout,
RescueStuckJobsAfter: rescueStuckJobs,
// 任务在单独的 trace 里执行,只链接到提交任务的 span,因为任务可能很久之后才执行,失败后还会重试,同一首歌的任务也由所有等待这首歌的房间共用。
// 任务在单独的 trace 中执行,只链接到提交它的 span:任务可能很久之后才执行,失败后还会重试,同一首歌的任务也由所有等待它的房间共用。
Plugins: []rivertype.Plugin{otelriver.NewMiddleware(&otelriver.MiddlewareConfig{TracerProvider: tracer, EnableTracePropagation: true})},
// River 在 info 级别记录每次启停和每个出错的任务,出错的任务由 Watch 记录。
// 放进 river 分组,River 自己的属性(如 job_id)才不会与日志的关联字段重名。
@@ -136,7 +136,7 @@ func Watch(client *river.Client[pgx.Tx], finished func(port.PrepareOutcome), obs
}
}
// River 记录取消的原因时带有 "JobCancelError: " 前缀。internal/adapter/out/queue 用同样的方式提取任务失败的原因。
// River 记录取消的原因时带有 "JobCancelError: " 前缀;internal/adapter/out/queue 以同样的方式提取任务失败的原因。
func reason(job *rivertype.JobRow) string {
if len(job.Errors) == 0 {
return "准备任务失败"
+5 -5
View File
@@ -38,7 +38,7 @@ func (t *Transcoder) ToMP3(ctx context.Context, audio port.LinkFile) (port.LinkF
return nil, nil, fmt.Errorf("创建临时目录:%w", err)
}
release := func() { _ = os.RemoveAll(dir) }
// 用 defer 是为了 panic 时也删除目录,因为 River 会恢复任务里的 panic,worker 还会继续运行。
// 用 defer 是为了 panic 时也删除目录:River 会恢复任务中的 panic,worker 继续运行。
done := false
defer func() {
if !done {
@@ -54,7 +54,7 @@ func (t *Transcoder) ToMP3(ctx context.Context, audio port.LinkFile) (port.LinkF
}
func (t *Transcoder) transcode(ctx context.Context, audio port.LinkFile, dir string) (port.LinkFile, error) {
// 先复制成文件再交给 ffmpeg,因为 MP4 的 moov 可能在文件末尾,从管道读不了。文件名不带扩展名,这样 ffmpeg 只能按内容识别格式。
// 先复制成文件再交给 ffmpeg:MP4 的 moov 可能在文件末尾,从管道读不了。文件名不带扩展名,ffmpeg 只能按内容识别格式。
input := filepath.Join(dir, "input")
switch err := copyFile(input, audio); {
case errors.Is(err, port.ErrAudioTooLarge):
@@ -65,8 +65,8 @@ func (t *Transcoder) transcode(ctx context.Context, audio port.LinkFile, dir str
output := filepath.Join(dir, "audio.mp3")
stderr, err := run(ctx, args(input, output))
if err == nil {
// 转码成功后立即删除输入副本,不等 release,因为源音频和 MP3 在上传期间还占着临时空间,
// 同一容器还可能在并行准备其他链接。
// 转码成功后立即删除输入副本,不等 release:源音频和 MP3 在上传期间仍占用临时空间,
// 同一容器还可能并行准备其他链接。
_ = os.Remove(input)
return file(output), nil
}
@@ -119,7 +119,7 @@ func args(input, output string) []string {
"-map_metadata", "-1", "-map_chapters", "-1",
// 与 yt-dlp 的 --audio-format mp3 默认质量一致。
"-c:a", "libmp3lame", "-q:a", "5",
// ffmpeg 写到不少于 -fs 的字节数就停止,并且正常退出。-fs 比上限多一个字节,截断的输出才一定超过上限,进而被准备流程拒绝。
// ffmpeg 写到不少于 -fs 的字节数就停止并正常退出。多一个字节,截断的输出才一定超过上限,由准备流程拒绝。
"-fs", strconv.Itoa(port.MaxAudioSize + 1),
output,
}
+7 -7
View File
@@ -194,7 +194,7 @@ func TestToMP3(t *testing.T) {
}
}
// 第二条音轨是声道更多的默认音轨,不指定映射时 ffmpeg 会选这一条。
// 第二条音轨是声道更多的默认音轨:不指定映射时 ffmpeg 会选它。
func TestToMP3UsesFirstAudioTrack(t *testing.T) {
input := generate(t, "two.mp4", slices.Concat(sine, []string{
"-f", "lavfi", "-i", "sine=frequency=880:duration=2", "-map", "0:a", "-map", "1:a", "-c:a", "aac",
@@ -219,7 +219,7 @@ func TestToMP3DropsSourceChapters(t *testing.T) {
}
}
// 和 yt-dlp 的默认质量一样用 VBR。ffmpeg 给码率变化的 mp3 写 Xing 头,固定码率时写 Info 头。
// 与 yt-dlp 的默认质量一样用 VBR:ffmpeg 给码率变化的 mp3 写 Xing 头,固定码率时写 Info 头。
func TestToMP3EncodesVBR(t *testing.T) {
input := generate(t, "audio.m4a", slices.Concat(sine, []string{"-c:a", "aac"})...)
mp3, release, err := New(t.TempDir(), discard).ToMP3(t.Context(), source(t, input))
@@ -258,7 +258,7 @@ func TestToMP3RejectsInputOverLimit(t *testing.T) {
rejected bool
logged int
}{
// 内容全是零,复制完之后 ffmpeg 认不出格式,所以转码失败,但不是因为超过上限。
// 内容全是零,复制完之后 ffmpeg 认不出格式:转码失败,但不是因为超过上限。
{port.MaxAudioSize, false, 1},
// 超过上限时不运行 ffmpeg,也就没有转码失败的日志。
{port.MaxAudioSize + 1, true, 0},
@@ -282,7 +282,7 @@ func TestToMP3RejectsInputOverLimit(t *testing.T) {
}
}
// 把 -fs 换成很小的值,看真实的 ffmpeg 怎样处理。期望截断时正常退出,输出不少于 -fs 的字节数。
// 把 -fs 换成很小的值,看真实的 ffmpeg 怎样处理:截断时正常退出,输出不少于 -fs 的字节数。
func TestOutputStopsAtFileSizeLimit(t *testing.T) {
input := generate(t, "long.m4a", "-f", "lavfi", "-i", "sine=frequency=440:duration=10", "-c:a", "aac")
output := filepath.Join(t.TempDir(), "audio.mp3")
@@ -326,8 +326,8 @@ func listenLocal(t *testing.T) (address string, connected func() bool) {
}
}
// 输入复制成了不带扩展名的文件,ffmpeg 不会把它当 HLS 播放列表,所以 HLS 一行走不到打开分片的一步。DASH 清单按内容识别,会走到那一步,然后被白名单拒绝。
// ffmpeg 读本地文件时默认的白名单是 file,crypto,data。stderr 里写的是 'file',说明命令行上的 -protocol_whitelist file 起了作用。
// 输入复制成了不带扩展名的文件,ffmpeg 不会把它当 HLS 播放列表,所以 HLS 一行走不到打开分片的一步;DASH 清单按内容识别,会走到那一步,被白名单拒绝。
// ffmpeg 读本地文件时默认的白名单是 file,crypto,data:stderr 里写着 'file',说明命令行上的 -protocol_whitelist file 起了作用。
func TestToMP3DoesNotUseNetwork(t *testing.T) {
tests := []struct {
name string
@@ -348,7 +348,7 @@ func TestToMP3DoesNotUseNetwork(t *testing.T) {
address, connected := listenLocal(t)
root := t.TempDir()
logger, records := recorder()
// 地址如果被访问,ffmpeg 会一直等响应,所以限定期限,免得挂住测试。
// 地址如果被访问,ffmpeg 会一直等响应:限定期限,免得挂住测试。
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
_, release, err := New(root, logger).ToMP3(ctx, porttest.LinkFile(fmt.Sprintf(tt.manifest, address)))
-139
View File
@@ -1,139 +0,0 @@
package github
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.GitHubOAuth = (*OAuth)(nil)
const (
timeout = 10 * time.Second
// GitHub 的这两个响应都只有几百字节。
maxResponseBytes = 64 << 10
)
type Endpoint struct {
AuthorizeURL string
TokenURL string
UserURL string
}
var GitHubCom = Endpoint{
AuthorizeURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
UserURL: "https://api.github.com/user",
}
type Config struct {
RedirectURL string
Endpoint Endpoint
}
type OAuth struct {
client *http.Client
cfg Config
}
func New(client *http.Client, cfg Config) *OAuth {
return &OAuth{client: client, cfg: cfg}
}
func (o *OAuth) AuthorizeURL(app port.GitHubOAuthApp, state, verifier string) string {
challenge := sha256.Sum256([]byte(verifier))
q := url.Values{
"client_id": {app.ClientID},
"redirect_uri": {o.cfg.RedirectURL},
"state": {state},
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
"code_challenge_method": {"S256"},
}
return o.cfg.Endpoint.AuthorizeURL + "?" + q.Encode()
}
func (o *OAuth) UserID(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
token, err := o.token(ctx, app, code, verifier)
if err != nil {
return 0, fmt.Errorf("向 GitHub 换取令牌:%w", err)
}
id, err := o.user(ctx, token)
if err != nil {
return 0, fmt.Errorf("读取 GitHub 用户:%w", err)
}
return id, nil
}
func (o *OAuth) token(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (string, error) {
form := url.Values{"client_id": {app.ClientID}, "client_secret": {app.ClientSecret}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {o.cfg.RedirectURL}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.cfg.Endpoint.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
// 授权码错误或过期时,GitHub 也以 200 返回,错误放在响应体的 error 中。
var body struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := o.do(req, &body); err != nil {
return "", err
}
if body.Error != "" {
return "", fmt.Errorf("GitHub 拒绝了授权码:%s(%s)", body.Error, body.ErrorDescription)
}
if body.AccessToken == "" {
return "", fmt.Errorf("GitHub 没有给出令牌")
}
return body.AccessToken, nil
}
func (o *OAuth) user(ctx context.Context, token string) (int64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, o.cfg.Endpoint.UserURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
// GitHub 的 REST API 拒绝不带 User-Agent 的请求。
req.Header.Set("User-Agent", "alisten")
var body struct {
ID int64 `json:"id"`
}
if err := o.do(req, &body); err != nil {
return 0, err
}
if body.ID <= 0 {
return 0, fmt.Errorf("GitHub 返回的用户 ID 不是正数:%d", body.ID)
}
return body.ID, nil
}
func (o *OAuth) do(req *http.Request, v any) error {
resp, err := o.client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub 返回 HTTP %d", resp.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(v); err != nil {
return fmt.Errorf("解码 GitHub 的响应:%w", err)
}
return nil
}
-170
View File
@@ -1,170 +0,0 @@
package github
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
const (
verifier = "dBjftJeZ4CVP-mJ92K1D5gV9dxWXJyqi5aZoxwYkrUM"
challenge = "kXQYIXxOaWujAPbjqX_yBdiwMfNi4RzDNMDwDNpE7QQ"
)
var app = port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}
// 假的 GitHub:授权码 good 签发时绑定了 challenge,带上 verifier 才能换到令牌 token-1,令牌 token-1 对应用户 42。
type fakeGitHub struct {
token func(w http.ResponseWriter, r *http.Request)
user func(w http.ResponseWriter, r *http.Request)
}
func newFake(t *testing.T) (*fakeGitHub, Config) {
t.Helper()
fake := &fakeGitHub{}
fake.token = func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Error(err)
}
if r.Method != http.MethodPost || r.Header.Get("Accept") != "application/json" {
t.Errorf("换取令牌的请求 = %s,Accept: %q", r.Method, r.Header.Get("Accept"))
}
want := url.Values{"client_id": {"client-1"}, "client_secret": {"secret-1"}, "code": {r.PostForm.Get("code")}, "code_verifier": {verifier}, "redirect_uri": {"https://alisten.example/api/auth/github/callback"}}
if r.PostForm.Encode() != want.Encode() {
t.Errorf("换取令牌的参数 = %s,期望 %s", r.PostForm.Encode(), want.Encode())
}
w.Header().Set("Content-Type", "application/json")
if r.PostForm.Get("code") == "good" {
_, _ = w.Write([]byte(`{"access_token":"token-1","token_type":"bearer","scope":""}`))
return
}
_, _ = w.Write([]byte(`{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired.","error_uri":"/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code"}`))
}
fake.user = func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer token-1" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"Bad credentials"}`))
return
}
if r.Header.Get("User-Agent") == "" || r.Header.Get("Accept") != "application/vnd.github+json" {
t.Errorf("用户接口的请求头 User-Agent: %q,Accept: %q", r.Header.Get("User-Agent"), r.Header.Get("Accept"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"octocat","id":42,"name":"The Octocat"}`))
}
mux := http.NewServeMux()
mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { fake.token(w, r) })
mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { fake.user(w, r) })
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return fake, Config{
RedirectURL: "https://alisten.example/api/auth/github/callback",
Endpoint: Endpoint{
AuthorizeURL: server.URL + "/login/oauth/authorize",
TokenURL: server.URL + "/login/oauth/access_token",
UserURL: server.URL + "/user",
},
}
}
func TestAuthorizeURL(t *testing.T) {
o := New(http.DefaultClient, Config{RedirectURL: "https://alisten.example/api/auth/github/callback", Endpoint: GitHubCom})
u, err := url.Parse(o.AuthorizeURL(app, "state 1&2", verifier))
if err != nil {
t.Fatal(err)
}
if got := u.Scheme + "://" + u.Host + u.Path; got != "https://github.com/login/oauth/authorize" {
t.Errorf("授权地址 = %s", got)
}
want := url.Values{
"client_id": {"client-1"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"redirect_uri": {"https://alisten.example/api/auth/github/callback"},
"state": {"state 1&2"},
}
if got := u.Query(); got.Encode() != want.Encode() {
t.Errorf("授权地址的参数 = %s,期望 %s:不申请任何 scope,只读公开资料", got.Encode(), want.Encode())
}
}
func TestUserID(t *testing.T) {
_, cfg := newFake(t)
o := New(http.DefaultClient, cfg)
if id, err := o.UserID(t.Context(), app, "good", verifier); err != nil || id != 42 {
t.Errorf("UserID = %d, %v,期望 42", id, err)
}
}
func TestUserIDFails(t *testing.T) {
for _, tc := range []struct {
name string
code string
token func(w http.ResponseWriter, r *http.Request)
user func(w http.ResponseWriter, r *http.Request)
want string
}{
{name: "授权码错误或过期", code: "expired", want: "bad_verification_code"},
{name: "令牌接口返回非 200", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}, want: "503"},
{name: "令牌接口返回的不是 JSON", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`access_token=token-1&token_type=bearer`))
}, want: "解码"},
{name: "令牌为空", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"access_token":""}`))
}, want: "没有给出令牌"},
{name: "用户接口拒绝令牌", code: "good", user: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}, want: "401"},
{name: "用户 ID 不是正数", code: "good", user: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"login":"ghost"}`))
}, want: "用户 ID"},
} {
t.Run(tc.name, func(t *testing.T) {
fake, cfg := newFake(t)
if tc.token != nil {
fake.token = tc.token
}
if tc.user != nil {
fake.user = tc.user
}
_, err := New(http.DefaultClient, cfg).UserID(t.Context(), app, tc.code, verifier)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("UserID 的错误 = %v,期望包含 %q", err, tc.want)
}
if err != nil && strings.Contains(err.Error(), "secret-1") {
t.Errorf("错误信息中不能有 client secret:%v", err)
}
})
}
}
// 不响应的 GitHub:请求一直挂着,直到请求的 ctx 结束。
type hangingTransport struct{}
func (hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
}
func TestUserIDTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
o := New(&http.Client{Transport: hangingTransport{}}, Config{Endpoint: GitHubCom})
start := time.Now()
if _, err := o.UserID(t.Context(), app, "good", verifier); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("GitHub 不响应时的错误 = %v,期望超过期限", err)
}
if elapsed := time.Since(start); elapsed != 10*time.Second {
t.Errorf("等了 %v 才中止,期望 10 秒", elapsed)
}
})
}
+1 -1
View File
@@ -41,7 +41,7 @@ func (s *Store) ResetRoomAPIKey(ctx context.Context, roomID string, k port.NewAP
return err
}
} else {
m, err := q.CreateMember(ctx, botName)
m, err := q.CreateMember(ctx, query.CreateMemberParams{TokenHash: k.BotTokenHash, Name: botName})
if err != nil {
return err
}
+4 -18
View File
@@ -2,7 +2,6 @@ package postgres
import (
"errors"
"reflect"
"testing"
"time"
@@ -13,7 +12,7 @@ import (
)
func newAPIKey(key string) port.NewAPIKey {
return port.NewAPIKey{TokenHash: tokenHash(key)}
return port.NewAPIKey{TokenHash: tokenHash(key), BotTokenHash: tokenHash("bot " + key)}
}
func resetAPIKey(t *testing.T, store *Store, roomID, key string) {
@@ -59,7 +58,7 @@ func TestResetRoomAPIKey(t *testing.T) {
if _, err := store.TicketHolder(t.Context(), tokenHash("k1")); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("重置之后旧 Key 的错误 = %v,期望 ErrTicketNotFound", err)
}
if holder, err := store.TicketHolder(t.Context(), tokenHash("k2")); err != nil || !reflect.DeepEqual(holder, bot) {
if holder, err := store.TicketHolder(t.Context(), tokenHash("k2")); err != nil || holder != bot {
t.Errorf("重置之后新 Key 的持有者 = %#v, %v,期望 bot 成员不变", holder, err)
}
if n := memberTickets(t, pool, bot.Member.ID); n != 1 {
@@ -85,7 +84,7 @@ func TestSettingPasswordKeepsAPIKey(t *testing.T) {
if err := store.UpdateRoomAccess(t.Context(), id, port.AccessChange{PasswordHash: ptr("h1")}); err != nil {
t.Fatal(err)
}
if holder, err := store.TicketHolder(t.Context(), tokenHash("k1")); err != nil || !reflect.DeepEqual(holder, bot) {
if holder, err := store.TicketHolder(t.Context(), tokenHash("k1")); err != nil || holder != bot {
t.Errorf("设置密码之后 Key 的持有者 = %#v, %v,期望 Key 仍然有效", holder, err)
}
if n := ticketCount(t, pool, id); n != 1 {
@@ -120,7 +119,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)
@@ -151,16 +150,3 @@ func TestConcurrentFirstResetsCreateOneBot(t *testing.T) {
t.Errorf("有 %d 把 Key、%d 个 bot 成员,期望各一个", n, bots)
}
}
func TestRoomBotHasNoSession(t *testing.T) {
store, pool := newStore(t)
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
resetAPIKey(t, store, id, "key-1")
var sessions int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM member_sessions s JOIN rooms r ON r.bot_member_id = s.member_id WHERE r.id = $1", id).Scan(&sessions); err != nil {
t.Fatal(err)
}
if sessions != 0 {
t.Errorf("bot 成员有 %d 个会话,期望没有:bot 只通过 API Key 使用 HTTP 接口", sessions)
}
}
@@ -1,57 +0,0 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.GitHubOAuthApps = (*Store)(nil)
const githubCredential = "github"
// Client ID 和 Client Secret 存在凭据表的同一行里,一起写入、一起删除。
type githubOAuthApp struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
func (s *Store) GitHubOAuthApp(ctx context.Context) (port.GitHubOAuthApp, error) {
value, err := s.q.Credential(ctx, githubCredential)
if errors.Is(err, pgx.ErrNoRows) {
return port.GitHubOAuthApp{}, nil
}
if err != nil {
return port.GitHubOAuthApp{}, fmt.Errorf("读取 GitHub OAuth App:%w", err)
}
var app githubOAuthApp
// 不包装解码的错误,因为解码的错误里可能带有原值的片段,也就可能带有 Client Secret。
if err := json.Unmarshal([]byte(value), &app); err != nil || app.ClientID == "" || app.ClientSecret == "" {
return port.GitHubOAuthApp{}, errors.New("凭据表中 GitHub OAuth App 的格式不正确")
}
return port.GitHubOAuthApp(app), nil
}
func (s *Store) SetGitHubOAuthApp(ctx context.Context, app port.GitHubOAuthApp) error {
value, err := json.Marshal(githubOAuthApp(app))
if err != nil {
return fmt.Errorf("编码 GitHub OAuth App:%w", err)
}
if err := s.q.SetCredential(ctx, query.SetCredentialParams{Name: githubCredential, Value: string(value)}); err != nil {
return fmt.Errorf("保存 GitHub OAuth App:%w", err)
}
return nil
}
func (s *Store) DeleteGitHubOAuthApp(ctx context.Context) error {
if err := s.q.DeleteCredential(ctx, githubCredential); err != nil {
return fmt.Errorf("清除 GitHub OAuth App:%w", err)
}
return nil
}
@@ -1,91 +0,0 @@
package postgres
import (
"fmt"
"strings"
"sync"
"testing"
"github.com/dihemoe/alisten/internal/app/port"
)
func TestGitHubOAuthApp(t *testing.T) {
store, pool := newStore(t)
ctx := t.Context()
if app, err := store.GitHubOAuthApp(ctx); err != nil || app != (port.GitHubOAuthApp{}) {
t.Fatalf("没有配置时 = %#v, %v,期望零值", app, err)
}
if err := store.SetNeteaseCookie(ctx, "MUSIC_U=1"); err != nil {
t.Fatal(err)
}
for _, want := range []port.GitHubOAuthApp{{ClientID: "client-1", ClientSecret: "secret-1"}, {ClientID: "client-2", ClientSecret: "secret-2"}} {
if err := store.SetGitHubOAuthApp(ctx, want); err != nil {
t.Fatal(err)
}
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != want {
t.Errorf("GitHubOAuthApp = %#v, %v,期望 %#v", got, err, want)
}
}
// 并发地保存和读取。读到的 Client ID 和 Secret 总是同一次保存的。
var wg sync.WaitGroup
for i := range 4 {
wg.Go(func() {
for j := range 20 {
app := port.GitHubOAuthApp{ClientID: fmt.Sprintf("client-%d-%d", i, j), ClientSecret: fmt.Sprintf("secret-%d-%d", i, j)}
if err := store.SetGitHubOAuthApp(ctx, app); err != nil {
t.Error(err)
return
}
}
})
wg.Go(func() {
for range 20 {
got, err := store.GitHubOAuthApp(ctx)
if err != nil {
t.Error(err)
return
}
if strings.TrimPrefix(got.ClientID, "client-") != strings.TrimPrefix(got.ClientSecret, "secret-") {
t.Errorf("读到的 Client ID 和 Secret 不是同一次保存的:%#v", got)
}
}
})
}
wg.Wait()
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
t.Fatal(err)
}
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != (port.GitHubOAuthApp{}) {
t.Errorf("清除之后 = %#v, %v,期望零值", got, err)
}
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
t.Errorf("没有配置时清除 = %v,期望什么也不做", err)
}
var rows int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM credentials").Scan(&rows); err != nil {
t.Fatal(err)
}
if cookie, err := store.NeteaseCookie(ctx); err != nil || cookie != "MUSIC_U=1" || rows != 1 {
t.Errorf("清除之后网易云 Cookie = %q, %v,凭据表有 %d 行,期望只删除 GitHub 的一行", cookie, err, rows)
}
}
// 存储里的值不是保存时的格式时报错,错误信息里不带原值,因为原值里可能有 Client Secret。
func TestGitHubOAuthAppRejectsCorruptValue(t *testing.T) {
store, pool := newStore(t)
for _, value := range []string{`not json secret-1`, `{"clientId":"client-1"}`, `{"clientSecret":"secret-1"}`} {
if _, err := pool.Exec(t.Context(), "INSERT INTO credentials (name, value) VALUES ('github', $1) ON CONFLICT (name) DO UPDATE SET value = excluded.value", value); err != nil {
t.Fatal(err)
}
_, err := store.GitHubOAuthApp(t.Context())
if err == nil {
t.Errorf("存储的值为 %q 时应报错", value)
continue
}
if strings.Contains(err.Error(), "secret-1") {
t.Errorf("错误信息中有原值:%v", err)
}
}
}
@@ -40,8 +40,8 @@ const lockRoomBot = `-- name: LockRoomBot :one
SELECT bot_member_id FROM rooms WHERE id = $1 FOR NO KEY UPDATE
`
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE,因为同一个房间的重置要依次进行。
// 这样同时到达的首次生成只会创建一个 bot 成员,最后只留下一把 Key。
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个房间的重置要依次进行,
// 同时到达的首次生成才只会创建一个 bot 成员,最后只留下一把 Key。
func (q *Queries) LockRoomBot(ctx context.Context, id string) (*string, error) {
row := q.db.QueryRow(ctx, lockRoomBot, id)
var bot_member_id *string
@@ -9,48 +9,6 @@ import (
"context"
)
// iteratorForInsertPlaylistItems implements pgx.CopyFromSource.
type iteratorForInsertPlaylistItems struct {
rows []InsertPlaylistItemsParams
skippedFirstNextCall bool
}
func (r *iteratorForInsertPlaylistItems) Next() bool {
if len(r.rows) == 0 {
return false
}
if !r.skippedFirstNextCall {
r.skippedFirstNextCall = true
return true
}
r.rows = r.rows[1:]
return len(r.rows) > 0
}
func (r iteratorForInsertPlaylistItems) Values() ([]interface{}, error) {
return []interface{}{
r.rows[0].PlaylistID,
r.rows[0].Source,
r.rows[0].TrackID,
r.rows[0].Title,
r.rows[0].Artist,
r.rows[0].Album,
r.rows[0].CoverURL,
r.rows[0].CoverKey,
r.rows[0].DurationMs,
r.rows[0].PageURL,
r.rows[0].Position,
}, nil
}
func (r iteratorForInsertPlaylistItems) Err() error {
return nil
}
func (q *Queries) InsertPlaylistItems(ctx context.Context, arg []InsertPlaylistItemsParams) (int64, error) {
return q.db.CopyFrom(ctx, []string{"playlist_items"}, []string{"playlist_id", "source", "track_id", "title", "artist", "album", "cover_url", "cover_key", "duration_ms", "page_url", "position"}, &iteratorForInsertPlaylistItems{rows: arg})
}
// iteratorForInsertPlays implements pgx.CopyFromSource.
type iteratorForInsertPlays struct {
rows []InsertPlaysParams
@@ -93,7 +93,7 @@ type AutoDJSeedsParams struct {
IncludeQueue bool
}
// 只看最近 1000 次播放。查询按 plays (room_id, started_at) 的索引倒序读,读到 1000 行就停,所以耗时与历史的长度无关。
// 只看最近 1000 次播放:按 plays (room_id, started_at) 的索引倒序读到 1000 行就停,耗时与历史的长度无关。
func (q *Queries) AutoDJSeeds(ctx context.Context, arg AutoDJSeedsParams) ([]string, error) {
rows, err := q.db.Query(ctx, autoDJSeeds, arg.RoomID, arg.IncludeQueue)
if err != nil {
@@ -1,234 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: listens.sql
package query
import (
"context"
"time"
)
const clearListens = `-- name: ClearListens :exec
DELETE FROM listens WHERE member_id = $1
`
func (q *Queries) ClearListens(ctx context.Context, memberID string) error {
_, err := q.db.Exec(ctx, clearListens, memberID)
return err
}
const deleteListen = `-- name: DeleteListen :exec
DELETE FROM listens WHERE member_id = $1 AND source = $2 AND track_id = $3
`
type DeleteListenParams struct {
MemberID string
Source string
TrackID string
}
func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) error {
_, err := q.db.Exec(ctx, deleteListen, arg.MemberID, arg.Source, arg.TrackID)
return err
}
const listenRecording = `-- name: ListenRecording :one
SELECT record_listens FROM members WHERE id = $1
`
func (q *Queries) ListenRecording(ctx context.Context, id string) (bool, error) {
row := q.db.QueryRow(ctx, listenRecording, id)
var record_listens bool
err := row.Scan(&record_listens)
return record_listens, err
}
const lockListeners = `-- name: LockListeners :many
SELECT id FROM members WHERE id = ANY($1::uuid[]) AND record_listens ORDER BY id FOR NO KEY UPDATE
`
// 按 ID 的顺序锁住开启了记录的成员。这样同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删。
// 加锁的顺序固定,所以同时写几个成员的事务之间不会死锁。
func (q *Queries) LockListeners(ctx context.Context, ids []string) ([]string, error) {
rows, err := q.db.Query(ctx, lockListeners, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const memberListens = `-- name: MemberListens :many
SELECT source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM listens WHERE member_id = $1
ORDER BY heard_at DESC, source, track_id
LIMIT $2
`
type MemberListensParams struct {
MemberID string
Max int32
}
type MemberListensRow struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
HeardAt time.Time
}
func (q *Queries) MemberListens(ctx context.Context, arg MemberListensParams) ([]MemberListensRow, error) {
rows, err := q.db.Query(ctx, memberListens, arg.MemberID, arg.Max)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MemberListensRow
for rows.Next() {
var i MemberListensRow
if err := rows.Scan(
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.DurationMs,
&i.PageURL,
&i.HeardAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveListens = `-- name: MoveListens :exec
WITH moved AS (
DELETE FROM listens WHERE member_id = $2::uuid
RETURNING source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
)
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT $1::uuid, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM moved
WHERE EXISTS (SELECT FROM members WHERE id = $1::uuid AND record_listens)
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at)
`
type MoveListensParams struct {
ToID string
FromID string
}
// 并入的规则与 UpsertListens 相同,并入的那份算新写入的。to_id 关闭了记录时只删不并。
func (q *Queries) MoveListens(ctx context.Context, arg MoveListensParams) error {
_, err := q.db.Exec(ctx, moveListens, arg.ToID, arg.FromID)
return err
}
const setListenRecording = `-- name: SetListenRecording :execrows
UPDATE members SET record_listens = $1 WHERE id = $2
`
type SetListenRecordingParams struct {
RecordListens bool
ID string
}
func (q *Queries) SetListenRecording(ctx context.Context, arg SetListenRecordingParams) (int64, error) {
result, err := q.db.Exec(ctx, setListenRecording, arg.RecordListens, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const trimListens = `-- name: TrimListens :exec
DELETE FROM listens l
USING (
SELECT member_id, source, track_id,
row_number() OVER (PARTITION BY member_id ORDER BY heard_at DESC, source, track_id) AS n
FROM listens WHERE member_id = ANY($1::uuid[])
) ranked
WHERE l.member_id = ranked.member_id AND l.source = ranked.source AND l.track_id = ranked.track_id AND ranked.n > $2::bigint
`
type TrimListensParams struct {
MemberIds []string
Keep int64
}
// 排序与 MemberListens 相同,所以留下的正是读取时返回的那些。
func (q *Queries) TrimListens(ctx context.Context, arg TrimListensParams) error {
_, err := q.db.Exec(ctx, trimListens, arg.MemberIds, arg.Keep)
return err
}
const upsertListens = `-- name: UpsertListens :exec
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT m, $1::text, $2::text, $3::text, $4::text, $5::text, $6::text,
$7::text, $8::bigint, $9::text, $10::timestamptz
FROM unnest($11::uuid[]) AS m
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at)
`
type UpsertListensParams struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
HeardAt time.Time
MemberIds []string
}
func (q *Queries) UpsertListens(ctx context.Context, arg UpsertListensParams) error {
_, err := q.db.Exec(ctx, upsertListens,
arg.Source,
arg.TrackID,
arg.Title,
arg.Artist,
arg.Album,
arg.CoverURL,
arg.CoverKey,
arg.DurationMs,
arg.PageURL,
arg.HeardAt,
arg.MemberIds,
)
return err
}
@@ -10,23 +10,6 @@ import (
"time"
)
const countStoredMedia = `-- name: CountStoredMedia :one
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
`
type CountStoredMediaRow struct {
Total int64
TotalBytes int64
}
func (q *Queries) CountStoredMedia(ctx context.Context, pattern string) (CountStoredMediaRow, error) {
row := q.db.QueryRow(ctx, countStoredMedia, pattern)
var i CountStoredMediaRow
err := row.Scan(&i.Total, &i.TotalBytes)
return i, err
}
const deleteStoredMedia = `-- name: DeleteStoredMedia :exec
DELETE FROM tracks WHERE media_key = $1::text
`
@@ -37,43 +20,24 @@ func (q *Queries) DeleteStoredMedia(ctx context.Context, mediaKey string) error
}
const listStoredMedia = `-- name: ListStoredMedia :many
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
ORDER BY CASE WHEN $2::text = 'stored' THEN prepared_at END DESC,
CASE WHEN $2::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
CASE WHEN $2::text = 'size' THEN media_size END DESC,
track_id COLLATE "C"
LIMIT $4::bigint OFFSET $3::bigint
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL
ORDER BY prepared_at DESC, track_id
`
type ListStoredMediaParams struct {
Pattern string
Sort string
Skip int64
PageSize int64
}
type ListStoredMediaRow struct {
MediaKey string
Source string
TrackID string
Title string
Artist *string
MediaSize int64
CoverURL *string
CoverKey *string
PreparedAt time.Time
LastPlayedAt *time.Time
}
// 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
func (q *Queries) ListStoredMedia(ctx context.Context, arg ListStoredMediaParams) ([]ListStoredMediaRow, error) {
rows, err := q.db.Query(ctx, listStoredMedia,
arg.Pattern,
arg.Sort,
arg.Skip,
arg.PageSize,
)
func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, error) {
rows, err := q.db.Query(ctx, listStoredMedia)
if err != nil {
return nil, err
}
@@ -86,9 +50,7 @@ func (q *Queries) ListStoredMedia(ctx context.Context, arg ListStoredMediaParams
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.MediaSize,
&i.CoverURL,
&i.CoverKey,
&i.PreparedAt,
&i.LastPlayedAt,
@@ -104,7 +66,7 @@ func (q *Queries) ListStoredMedia(ctx context.Context, arg ListStoredMediaParams
}
const storedMedia = `-- name: StoredMedia :one
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key = $1::text
`
@@ -113,9 +75,7 @@ type StoredMediaRow struct {
Source string
TrackID string
Title string
Artist *string
MediaSize int64
CoverURL *string
CoverKey *string
PreparedAt time.Time
LastPlayedAt *time.Time
@@ -129,9 +89,7 @@ func (q *Queries) StoredMedia(ctx context.Context, mediaKey string) (StoredMedia
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.MediaSize,
&i.CoverURL,
&i.CoverKey,
&i.PreparedAt,
&i.LastPlayedAt,
@@ -9,231 +9,31 @@ import (
"context"
)
const accountByGitHub = `-- name: AccountByGitHub :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE github_id = $1
`
func (q *Queries) AccountByGitHub(ctx context.Context, githubID *int64) (Member, error) {
row := q.db.QueryRow(ctx, accountByGitHub, githubID)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const accountByID = `-- name: AccountByID :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = $1 AND (username IS NOT NULL OR github_id IS NOT NULL)
`
func (q *Queries) AccountByID(ctx context.Context, id string) (Member, error) {
row := q.db.QueryRow(ctx, accountByID, id)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const accountByUsername = `-- name: AccountByUsername :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE username = $1
`
func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) {
row := q.db.QueryRow(ctx, accountByUsername, username)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const addMemberCredentials = `-- name: AddMemberCredentials :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL AND github_id IS NOT NULL
`
type AddMemberCredentialsParams struct {
Username *string
PasswordHash *string
ID string
}
// 只给只绑定了 GitHub 的账号设置。这种账号的“我喜欢”已经在绑定时创建了。
func (q *Queries) AddMemberCredentials(ctx context.Context, arg AddMemberCredentialsParams) (int64, error) {
result, err := q.db.Exec(ctx, addMemberCredentials, arg.Username, arg.PasswordHash, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const bindGitHub = `-- name: BindGitHub :one
UPDATE members SET github_id = $1
WHERE id = $2 AND github_id IS NULL
RETURNING (username IS NULL)::boolean AS was_guest
`
type BindGitHubParams struct {
GithubID *int64
ID string
}
// 只给还没有绑定 GitHub 的成员绑定。已经绑定过的成员不受影响,查询没有返回的行。was_guest 表示成员原来是游客,这次绑定就是注册。
func (q *Queries) BindGitHub(ctx context.Context, arg BindGitHubParams) (bool, error) {
row := q.db.QueryRow(ctx, bindGitHub, arg.GithubID, arg.ID)
var was_guest bool
err := row.Scan(&was_guest)
return was_guest, err
}
const createMember = `-- name: CreateMember :one
INSERT INTO members (name) VALUES ($1)
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
INSERT INTO members (token_hash, name) VALUES ($1, $2)
RETURNING id, token_hash, name, avatar_hash, created_at
`
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
row := q.db.QueryRow(ctx, createMember, name)
type CreateMemberParams struct {
TokenHash []byte
Name string
}
func (q *Queries) CreateMember(ctx context.Context, arg CreateMemberParams) (Member, error) {
row := q.db.QueryRow(ctx, createMember, arg.TokenHash, arg.Name)
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const createSession = `-- name: CreateSession :exec
INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)
`
type CreateSessionParams struct {
TokenHash []byte
MemberID string
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error {
_, err := q.db.Exec(ctx, createSession, arg.TokenHash, arg.MemberID)
return err
}
const deleteMemberSessions = `-- name: DeleteMemberSessions :many
DELETE FROM member_sessions WHERE member_id = $1 AND token_hash IS DISTINCT FROM $2::bytea
RETURNING token_hash
`
type DeleteMemberSessionsParams struct {
MemberID string
Keep []byte
}
// keep 为 NULL 时删除这个成员的全部会话。
func (q *Queries) DeleteMemberSessions(ctx context.Context, arg DeleteMemberSessionsParams) ([][]byte, error) {
rows, err := q.db.Query(ctx, deleteMemberSessions, arg.MemberID, arg.Keep)
if err != nil {
return nil, err
}
defer rows.Close()
var items [][]byte
for rows.Next() {
var token_hash []byte
if err := rows.Scan(&token_hash); err != nil {
return nil, err
}
items = append(items, token_hash)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteSession = `-- name: DeleteSession :execrows
DELETE FROM member_sessions WHERE token_hash = $1
`
func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) (int64, error) {
result, err := q.db.Exec(ctx, deleteSession, tokenHash)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
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。这样同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
func (q *Queries) LockAccountPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockAccountPassword, id)
var password_hash string
err := row.Scan(&password_hash)
return password_hash, err
}
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,这样同一个账号的几次登录会依次并入游客的历史。否则两次登录会按相反的顺序抢同一批历史行而死锁。
func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockMemberPassword, id)
var password_hash string
err := row.Scan(&password_hash)
return password_hash, err
}
const memberAccountProfile = `-- name: MemberAccountProfile :one
SELECT username, email, github_id FROM members WHERE id = $1
`
type MemberAccountProfileRow struct {
Username *string
Email *string
GithubID *int64
}
func (q *Queries) MemberAccountProfile(ctx context.Context, id string) (MemberAccountProfileRow, error) {
row := q.db.QueryRow(ctx, memberAccountProfile, id)
var i MemberAccountProfileRow
err := row.Scan(&i.Username, &i.Email, &i.GithubID)
return i, err
}
const memberByToken = `-- name: MemberByToken :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
SELECT id, token_hash, name, avatar_hash, created_at FROM members WHERE token_hash = $1
`
func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) {
@@ -241,130 +41,38 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const memberHasSession = `-- name: MemberHasSession :one
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = $1 AND member_id = $2)
`
type MemberHasSessionParams struct {
TokenHash []byte
MemberID string
}
func (q *Queries) MemberHasSession(ctx context.Context, arg MemberHasSessionParams) (bool, error) {
row := q.db.QueryRow(ctx, memberHasSession, arg.TokenHash, arg.MemberID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const registerMember = `-- name: RegisterMember :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL AND github_id IS NULL
`
type RegisterMemberParams struct {
Username *string
PasswordHash *string
ID string
}
// 只给游客设置。只绑定了 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 {
return 0, err
}
return result.RowsAffected(), nil
}
const setMemberPassword = `-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = $1 WHERE id = $2 AND username IS NOT NULL
`
type SetMemberPasswordParams struct {
PasswordHash *string
ID string
}
func (q *Queries) SetMemberPassword(ctx context.Context, arg SetMemberPasswordParams) (int64, error) {
result, err := q.db.Exec(ctx, setMemberPassword, arg.PasswordHash, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const takeSession = `-- name: TakeSession :one
DELETE FROM member_sessions WHERE token_hash = $1 RETURNING member_id
`
func (q *Queries) TakeSession(ctx context.Context, tokenHash []byte) (string, error) {
row := q.db.QueryRow(ctx, takeSession, tokenHash)
var member_id string
err := row.Scan(&member_id)
return member_id, err
}
const unbindGitHub = `-- name: UnbindGitHub :execrows
UPDATE members SET github_id = NULL WHERE id = $1 AND password_hash IS NOT NULL
`
// 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
func (q *Queries) UnbindGitHub(ctx context.Context, id string) (int64, error) {
result, err := q.db.Exec(ctx, unbindGitHub, id)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateMember = `-- name: UpdateMember :one
UPDATE members
SET name = coalesce($1, name),
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END,
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
WHERE id = $4
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END
WHERE id = $3
RETURNING id, token_hash, name, avatar_hash, created_at
`
type UpdateMemberParams struct {
Name *string
AvatarHash *string
Email *string
ID string
}
// 只覆盖传入的列,所以分别修改昵称和头像的两个并发请求不会互相还原。头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。
func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Member, error) {
row := q.db.QueryRow(ctx, updateMember,
arg.Name,
arg.AvatarHash,
arg.Email,
arg.ID,
)
row := q.db.QueryRow(ctx, updateMember, arg.Name, arg.AvatarHash, arg.ID)
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -9,30 +9,11 @@ import (
)
type Member struct {
ID string
Name string
AvatarHash *string
CreatedAt time.Time
Username *string
PasswordHash *string
Email *string
RecordListens bool
GithubID *int64
}
type PlaylistItem struct {
PlaylistID string
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
Position int64
AddedAt time.Time
ID string
TokenHash []byte
Name string
AvatarHash *string
CreatedAt time.Time
}
type Room struct {
@@ -1,601 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: playlists.sql
package query
import (
"context"
"time"
)
const copyPlaylistItems = `-- name: CopyPlaylistItems :execrows
WITH wanted AS (
SELECT s.source, k.track_id, min(s.ord) AS ord
FROM unnest($3::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest($4::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
GROUP BY s.source, k.track_id
), copied AS (
SELECT i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY w.ord DESC) AS rank
FROM wanted w
JOIN playlist_items i ON i.playlist_id = $5 AND i.source = w.source AND i.track_id = w.track_id
WHERE NOT EXISTS (
SELECT FROM playlist_items d WHERE d.playlist_id = $1 AND d.source = w.source AND d.track_id = w.track_id
)
)
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT $1, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, $2::bigint + rank
FROM copied
`
type CopyPlaylistItemsParams struct {
ToID string
ItemSeq int64
Sources []string
TrackIds []string
FromID string
}
// 按请求的先后顺序复制,第一首的排序值最大。请求里重复的歌只取第一次出现的位置。
func (q *Queries) CopyPlaylistItems(ctx context.Context, arg CopyPlaylistItemsParams) (int64, error) {
result, err := q.db.Exec(ctx, copyPlaylistItems,
arg.ToID,
arg.ItemSeq,
arg.Sources,
arg.TrackIds,
arg.FromID,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const copySharedPlaylistItems = `-- name: CopySharedPlaylistItems :one
WITH shared AS (
SELECT id FROM playlists WHERE share_id = $1::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT $2, i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied
`
type CopySharedPlaylistItemsParams struct {
ShareID string
ToID string
}
type CopySharedPlaylistItemsRow struct {
Found bool
Copied int64
}
// 确认分享还在和复制曲目放在同一条语句里,两者看到的是同一个快照,所以要么完整复制,要么读不到分享。不锁来源歌单,因为两个人同时复制
// 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
func (q *Queries) CopySharedPlaylistItems(ctx context.Context, arg CopySharedPlaylistItemsParams) (CopySharedPlaylistItemsRow, error) {
row := q.db.QueryRow(ctx, copySharedPlaylistItems, arg.ShareID, arg.ToID)
var i CopySharedPlaylistItemsRow
err := row.Scan(&i.Found, &i.Copied)
return i, err
}
const countPlaylistItems = `-- name: CountPlaylistItems :one
SELECT count(*) FROM playlist_items WHERE playlist_id = $1
`
func (q *Queries) CountPlaylistItems(ctx context.Context, playlistID string) (int64, error) {
row := q.db.QueryRow(ctx, countPlaylistItems, playlistID)
var count int64
err := row.Scan(&count)
return count, err
}
const countPlaylists = `-- name: CountPlaylists :one
SELECT count(*) FROM playlists WHERE owner_id = $1
`
func (q *Queries) CountPlaylists(ctx context.Context, ownerID string) (int64, error) {
row := q.db.QueryRow(ctx, countPlaylists, ownerID)
var count int64
err := row.Scan(&count)
return count, err
}
const createFavorites = `-- name: CreateFavorites :exec
INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'favorites', '我喜欢')
`
func (q *Queries) CreateFavorites(ctx context.Context, ownerID string) error {
_, err := q.db.Exec(ctx, createFavorites, ownerID)
return err
}
const createPlaylist = `-- name: CreatePlaylist :one
INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'custom', $2)
RETURNING id, created_at, updated_at
`
type CreatePlaylistParams struct {
OwnerID string
Name string
}
type CreatePlaylistRow struct {
ID string
CreatedAt time.Time
UpdatedAt time.Time
}
func (q *Queries) CreatePlaylist(ctx context.Context, arg CreatePlaylistParams) (CreatePlaylistRow, error) {
row := q.db.QueryRow(ctx, createPlaylist, arg.OwnerID, arg.Name)
var i CreatePlaylistRow
err := row.Scan(&i.ID, &i.CreatedAt, &i.UpdatedAt)
return i, err
}
const deletePlaylist = `-- name: DeletePlaylist :execrows
DELETE FROM playlists WHERE id = $1 AND owner_id = $2 AND kind = 'custom'
`
type DeletePlaylistParams struct {
ID string
OwnerID string
}
func (q *Queries) DeletePlaylist(ctx context.Context, arg DeletePlaylistParams) (int64, error) {
result, err := q.db.Exec(ctx, deletePlaylist, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deletePlaylistItems = `-- name: DeletePlaylistItems :exec
WITH deleted AS (
DELETE FROM playlist_items i
USING unnest($1::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest($2::text[]) WITH ORDINALITY AS t(track_id, ord) USING (ord)
WHERE i.playlist_id = $3 AND i.source = s.source AND i.track_id = t.track_id
RETURNING i.playlist_id
)
UPDATE playlists SET updated_at = now() WHERE id IN (SELECT playlist_id FROM deleted)
`
type DeletePlaylistItemsParams struct {
Sources []string
TrackIds []string
PlaylistID string
}
func (q *Queries) DeletePlaylistItems(ctx context.Context, arg DeletePlaylistItemsParams) error {
_, err := q.db.Exec(ctx, deletePlaylistItems, arg.Sources, arg.TrackIds, arg.PlaylistID)
return err
}
const insertPlaylistItem = `-- name: InsertPlaylistItem :exec
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
type InsertPlaylistItemParams struct {
PlaylistID string
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
Position int64
}
func (q *Queries) InsertPlaylistItem(ctx context.Context, arg InsertPlaylistItemParams) error {
_, err := q.db.Exec(ctx, insertPlaylistItem,
arg.PlaylistID,
arg.Source,
arg.TrackID,
arg.Title,
arg.Artist,
arg.Album,
arg.CoverURL,
arg.CoverKey,
arg.DurationMs,
arg.PageURL,
arg.Position,
)
return err
}
type InsertPlaylistItemsParams struct {
PlaylistID string
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
Position int64
}
const lockFavorites = `-- name: LockFavorites :exec
SELECT id FROM playlists WHERE owner_id = $1 AND kind = 'favorites' FOR UPDATE
`
// 新建歌单前锁住这个账号的“我喜欢”,这样同一个账号并发新建时会依次计数,不会越过上限。
func (q *Queries) LockFavorites(ctx context.Context, ownerID string) error {
_, err := q.db.Exec(ctx, lockFavorites, ownerID)
return err
}
const lockPlaylist = `-- name: LockPlaylist :one
SELECT item_seq FROM playlists WHERE id = $1 AND owner_id = $2 FOR UPDATE
`
type LockPlaylistParams struct {
ID string
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
err := row.Scan(&item_seq)
return item_seq, err
}
const memberPlaylistCovers = `-- name: MemberPlaylistCovers :many
SELECT DISTINCT ON (i.playlist_id) i.playlist_id, i.cover_url, i.cover_key
FROM playlist_items i
JOIN playlists p ON p.id = i.playlist_id
WHERE p.owner_id = $1 AND (i.cover_url IS NOT NULL OR i.cover_key IS NOT NULL)
ORDER BY i.playlist_id, i.position DESC
`
type MemberPlaylistCoversRow struct {
PlaylistID string
CoverURL *string
CoverKey *string
}
func (q *Queries) MemberPlaylistCovers(ctx context.Context, ownerID string) ([]MemberPlaylistCoversRow, error) {
rows, err := q.db.Query(ctx, memberPlaylistCovers, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MemberPlaylistCoversRow
for rows.Next() {
var i MemberPlaylistCoversRow
if err := rows.Scan(&i.PlaylistID, &i.CoverURL, &i.CoverKey); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const memberPlaylists = `-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = $1
GROUP BY p.id
ORDER BY p.kind = 'favorites' DESC, p.created_at, p.id
`
type MemberPlaylistsRow struct {
ID string
Kind string
Name string
CreatedAt time.Time
UpdatedAt time.Time
ShareID *string
TrackCount int64
}
func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]MemberPlaylistsRow, error) {
rows, err := q.db.Query(ctx, memberPlaylists, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MemberPlaylistsRow
for rows.Next() {
var i MemberPlaylistsRow
if err := rows.Scan(
&i.ID,
&i.Kind,
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
&i.ShareID,
&i.TrackCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const playlistCover = `-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = $1 AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1
`
type PlaylistCoverRow struct {
CoverURL *string
CoverKey *string
}
func (q *Queries) PlaylistCover(ctx context.Context, playlistID string) (PlaylistCoverRow, error) {
row := q.db.QueryRow(ctx, playlistCover, playlistID)
var i PlaylistCoverRow
err := row.Scan(&i.CoverURL, &i.CoverKey)
return i, err
}
const playlistHasItem = `-- name: PlaylistHasItem :one
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = $1 AND source = $2 AND track_id = $3)
`
type PlaylistHasItemParams struct {
PlaylistID string
Source string
TrackID string
}
func (q *Queries) PlaylistHasItem(ctx context.Context, arg PlaylistHasItemParams) (bool, error) {
row := q.db.QueryRow(ctx, playlistHasItem, arg.PlaylistID, arg.Source, arg.TrackID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const playlistItems = `-- name: PlaylistItems :many
SELECT playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position, added_at FROM playlist_items WHERE playlist_id = $1 ORDER BY position DESC
`
func (q *Queries) PlaylistItems(ctx context.Context, playlistID string) ([]PlaylistItem, error) {
rows, err := q.db.Query(ctx, playlistItems, playlistID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []PlaylistItem
for rows.Next() {
var i PlaylistItem
if err := rows.Scan(
&i.PlaylistID,
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.DurationMs,
&i.PageURL,
&i.Position,
&i.AddedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const playlistKind = `-- name: PlaylistKind :one
SELECT kind FROM playlists WHERE id = $1 AND owner_id = $2
`
type PlaylistKindParams struct {
ID string
OwnerID string
}
func (q *Queries) PlaylistKind(ctx context.Context, arg PlaylistKindParams) (string, error) {
row := q.db.QueryRow(ctx, playlistKind, arg.ID, arg.OwnerID)
var kind string
err := row.Scan(&kind)
return kind, err
}
const playlistTracks = `-- name: PlaylistTracks :many
SELECT source, track_id FROM playlist_items WHERE playlist_id = $1 ORDER BY position DESC
`
type PlaylistTracksRow struct {
Source string
TrackID string
}
func (q *Queries) PlaylistTracks(ctx context.Context, playlistID string) ([]PlaylistTracksRow, error) {
rows, err := q.db.Query(ctx, playlistTracks, playlistID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []PlaylistTracksRow
for rows.Next() {
var i PlaylistTracksRow
if err := rows.Scan(&i.Source, &i.TrackID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const renamePlaylist = `-- name: RenamePlaylist :execrows
UPDATE playlists SET name = $1, updated_at = now() WHERE id = $2 AND owner_id = $3 AND kind = 'custom'
`
type RenamePlaylistParams struct {
Name string
ID string
OwnerID string
}
func (q *Queries) RenamePlaylist(ctx context.Context, arg RenamePlaylistParams) (int64, error) {
result, err := q.db.Exec(ctx, renamePlaylist, arg.Name, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const setPlaylistPositions = `-- name: SetPlaylistPositions :exec
UPDATE playlist_items i SET position = t.position
FROM unnest($2::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest($3::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN unnest($4::bigint[]) WITH ORDINALITY AS t(position, ord) USING (ord)
WHERE i.playlist_id = $1 AND i.source = s.source AND i.track_id = k.track_id
`
type SetPlaylistPositionsParams struct {
PlaylistID string
Sources []string
TrackIds []string
Positions []int64
}
func (q *Queries) SetPlaylistPositions(ctx context.Context, arg SetPlaylistPositionsParams) error {
_, err := q.db.Exec(ctx, setPlaylistPositions,
arg.PlaylistID,
arg.Sources,
arg.TrackIds,
arg.Positions,
)
return err
}
const setPlaylistSeq = `-- name: SetPlaylistSeq :exec
UPDATE playlists SET item_seq = $1, updated_at = now() WHERE id = $2
`
type SetPlaylistSeqParams struct {
ItemSeq int64
ID string
}
func (q *Queries) SetPlaylistSeq(ctx context.Context, arg SetPlaylistSeqParams) error {
_, err := q.db.Exec(ctx, setPlaylistSeq, arg.ItemSeq, arg.ID)
return err
}
const setPlaylistShare = `-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = $1 WHERE id = $2 AND owner_id = $3
`
type SetPlaylistShareParams struct {
ShareID *string
ID string
OwnerID string
}
func (q *Queries) SetPlaylistShare(ctx context.Context, arg SetPlaylistShareParams) (int64, error) {
result, err := q.db.Exec(ctx, setPlaylistShare, arg.ShareID, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const sharedPlaylist = `-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = $1::text
`
type SharedPlaylistRow struct {
ID string
Kind string
Name string
OwnerName string
OwnerAvatarHash *string
}
func (q *Queries) SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylistRow, error) {
row := q.db.QueryRow(ctx, sharedPlaylist, shareID)
var i SharedPlaylistRow
err := row.Scan(
&i.ID,
&i.Kind,
&i.Name,
&i.OwnerName,
&i.OwnerAvatarHash,
)
return i, err
}
const touchPlaylist = `-- name: TouchPlaylist :exec
UPDATE playlists SET updated_at = now() WHERE id = $1
`
func (q *Queries) TouchPlaylist(ctx context.Context, id string) error {
_, err := q.db.Exec(ctx, touchPlaylist, id)
return err
}
const trackPlaylists = `-- name: TrackPlaylists :many
SELECT p.id FROM playlists p
JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = $1 AND i.source = $2 AND i.track_id = $3
ORDER BY p.id
`
type TrackPlaylistsParams struct {
OwnerID string
Source string
TrackID string
}
func (q *Queries) TrackPlaylists(ctx context.Context, arg TrackPlaylistsParams) ([]string, error) {
rows, err := q.db.Query(ctx, trackPlaylists, arg.OwnerID, arg.Source, arg.TrackID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -9,38 +9,23 @@ import (
"context"
)
const brandName = `-- name: BrandName :one
SELECT brand_name FROM site
`
func (q *Queries) BrandName(ctx context.Context) (string, error) {
row := q.db.QueryRow(ctx, brandName)
var brand_name string
err := row.Scan(&brand_name)
return brand_name, err
}
const setBrandName = `-- name: SetBrandName :exec
INSERT INTO site (brand_name) VALUES ($1)
ON CONFLICT (id) DO UPDATE SET brand_name = excluded.brand_name
`
func (q *Queries) SetBrandName(ctx context.Context, brandName *string) error {
func (q *Queries) SetBrandName(ctx context.Context, brandName string) error {
_, err := q.db.Exec(ctx, setBrandName, brandName)
return err
}
const setRegistrationOpen = `-- name: SetRegistrationOpen :exec
INSERT INTO site (registration_open) VALUES ($1)
ON CONFLICT (id) DO UPDATE SET registration_open = excluded.registration_open
`
func (q *Queries) SetRegistrationOpen(ctx context.Context, registrationOpen bool) error {
_, err := q.db.Exec(ctx, setRegistrationOpen, registrationOpen)
return err
}
const siteSettings = `-- name: SiteSettings :one
SELECT brand_name, registration_open FROM site
`
type SiteSettingsRow struct {
BrandName *string
RegistrationOpen bool
}
func (q *Queries) SiteSettings(ctx context.Context) (SiteSettingsRow, error) {
row := q.db.QueryRow(ctx, siteSettings)
var i SiteSettingsRow
err := row.Scan(&i.BrandName, &i.RegistrationOpen)
return i, err
}
@@ -10,57 +10,25 @@ import (
)
const createTicket = `-- name: CreateTicket :exec
INSERT INTO room_tickets (token_hash, room_id, member_id, session_token_hash)
VALUES ($1, $2, $3, $4)
INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES ($1, $2, $3)
`
type CreateTicketParams struct {
TokenHash []byte
RoomID string
MemberID string
SessionTokenHash []byte
TokenHash []byte
RoomID string
MemberID string
}
// bot 的 API Key 没有会话,session_token_hash 为 NULL。
func (q *Queries) CreateTicket(ctx context.Context, arg CreateTicketParams) error {
_, err := q.db.Exec(ctx, createTicket,
arg.TokenHash,
arg.RoomID,
arg.MemberID,
arg.SessionTokenHash,
)
_, err := q.db.Exec(ctx, createTicket, arg.TokenHash, arg.RoomID, arg.MemberID)
return err
}
const existingTickets = `-- name: ExistingTickets :many
SELECT token_hash FROM room_tickets WHERE token_hash = ANY($1::bytea[])
`
func (q *Queries) ExistingTickets(ctx context.Context, tokenHashes [][]byte) ([][]byte, error) {
rows, err := q.db.Query(ctx, existingTickets, tokenHashes)
if err != nil {
return nil, err
}
defer rows.Close()
var items [][]byte
for rows.Next() {
var token_hash []byte
if err := rows.Scan(&token_hash); err != nil {
return nil, err
}
items = append(items, token_hash)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockRoomPassword = `-- name: LockRoomPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM rooms WHERE id = $1 FOR SHARE
`
// FOR SHARE 让修改密码和删除房间等到这次签发提交之后才进行。这样两者随后删除票据时,会把这一张也删掉。
// FOR SHARE 让修改密码和删除房间等到这次签发提交之后,它们随后删除票据时会删掉这一张。
func (q *Queries) LockRoomPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockRoomPassword, id)
var password_hash string
@@ -69,7 +37,7 @@ func (q *Queries) LockRoomPassword(ctx context.Context, id string) (string, erro
}
const ticketHolder = `-- name: TicketHolder :one
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot, t.session_token_hash
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot
FROM room_tickets t
JOIN members m ON m.id = t.member_id
JOIN rooms r ON r.id = t.room_id
@@ -77,12 +45,11 @@ WHERE t.token_hash = $1
`
type TicketHolderRow struct {
RoomID string
ID string
Name string
AvatarHash *string
Bot bool
SessionTokenHash []byte
RoomID string
ID string
Name string
AvatarHash *string
Bot bool
}
func (q *Queries) TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolderRow, error) {
@@ -94,7 +61,6 @@ func (q *Queries) TicketHolder(ctx context.Context, tokenHash []byte) (TicketHol
&i.Name,
&i.AvatarHash,
&i.Bot,
&i.SessionTokenHash,
)
return i, err
}
@@ -85,7 +85,7 @@ type SavePreparedTrackParams struct {
MediaSize *int64
}
// 准备任务只对未完成的任务去重,所以同一首歌可能先后准备两次,以后一次的结果为准。
// 准备任务只对未完成的任务去重,同一首歌可能先后准备两次,以后一次的结果为准。
func (q *Queries) SavePreparedTrack(ctx context.Context, arg SavePreparedTrackParams) error {
_, err := q.db.Exec(ctx, savePreparedTrack,
arg.Source,
@@ -106,57 +106,3 @@ func (q *Queries) SavePreparedTrack(ctx context.Context, arg SavePreparedTrackPa
)
return err
}
const trackMetas = `-- name: TrackMetas :many
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
FROM unnest($1::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest($2::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id
`
type TrackMetasParams struct {
Sources []string
TrackIds []string
}
type TrackMetasRow struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
PageURL string
DurationMs int64
}
func (q *Queries) TrackMetas(ctx context.Context, arg TrackMetasParams) ([]TrackMetasRow, error) {
rows, err := q.db.Query(ctx, trackMetas, arg.Sources, arg.TrackIds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TrackMetasRow
for rows.Next() {
var i TrackMetasRow
if err := rows.Scan(
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.PageURL,
&i.DurationMs,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
-119
View File
@@ -1,119 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
_ port.ListenRecorder = (*Store)(nil)
_ port.Listens = (*Store)(nil)
)
func (s *Store) RecordListen(ctx context.Context, memberIDs []string, l port.Listen) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
ids, err := q.LockListeners(ctx, memberIDs)
if err != nil || len(ids) == 0 {
return err
}
m := l.Meta
err = q.UpsertListens(ctx, query.UpsertListensParams{
MemberIds: ids,
Source: string(l.Track.Source),
TrackID: l.Track.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
HeardAt: l.HeardAt,
})
if err != nil {
return err
}
return q.TrimListens(ctx, query.TrimListensParams{MemberIds: ids, Keep: port.ListenLimit})
})
if err != nil {
return fmt.Errorf("给 %d 个成员记录听到的 %s:%s:%w", len(memberIDs), l.Track.Source, l.Track.ID, err)
}
return nil
}
// 要在替换会话的事务里调用。这时账号行已经被互斥地锁住,所以 moveListens 和同时进行的记录、同时登录这个账号的其他游客会依次写入账号的历史。
func moveListens(ctx context.Context, q *query.Queries, fromID, toID string) error {
if err := q.MoveListens(ctx, query.MoveListensParams{FromID: fromID, ToID: toID}); err != nil {
return err
}
return q.TrimListens(ctx, query.TrimListensParams{MemberIds: []string{toID}, Keep: port.ListenLimit})
}
func (s *Store) MemberListens(ctx context.Context, memberID string) ([]port.Listen, error) {
rows, err := s.q.MemberListens(ctx, query.MemberListensParams{MemberID: memberID, Max: port.ListenLimit})
if err != nil {
return nil, fmt.Errorf("读取成员 %s 的播放历史:%w", memberID, err)
}
listens := make([]port.Listen, len(rows))
for i, r := range rows {
listens[i] = port.Listen{
Track: track.Track{Source: track.Source(r.Source), ID: r.TrackID},
Meta: track.Meta{
Title: r.Title,
Artist: orEmpty(r.Artist),
Album: orEmpty(r.Album),
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
PageURL: r.PageURL,
Duration: time.Duration(r.DurationMs) * time.Millisecond,
},
HeardAt: r.HeardAt,
}
}
return listens, nil
}
func (s *Store) DeleteListen(ctx context.Context, memberID string, t track.Track) error {
if err := s.q.DeleteListen(ctx, query.DeleteListenParams{MemberID: memberID, Source: string(t.Source), TrackID: t.ID}); err != nil {
return fmt.Errorf("删除成员 %s 听过的 %s:%s:%w", memberID, t.Source, t.ID, err)
}
return nil
}
func (s *Store) ClearListens(ctx context.Context, memberID string) error {
if err := s.q.ClearListens(ctx, memberID); err != nil {
return fmt.Errorf("清空成员 %s 的播放历史:%w", memberID, err)
}
return nil
}
func (s *Store) ListenRecording(ctx context.Context, memberID string) (bool, error) {
on, err := s.q.ListenRecording(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return false, fmt.Errorf("读取成员 %s 是否记录播放历史:%w", memberID, err)
}
return on, nil
}
func (s *Store) SetListenRecording(ctx context.Context, memberID string, on bool) error {
n, err := s.q.SetListenRecording(ctx, query.SetListenRecordingParams{ID: memberID, RecordListens: on})
if err == nil && n == 0 {
err = port.ErrMemberNotFound
}
if err != nil {
return fmt.Errorf("设置成员 %s 是否记录播放历史:%w", memberID, err)
}
return nil
}
@@ -1,346 +0,0 @@
package postgres
import (
"errors"
"fmt"
"slices"
"sync"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var heard = time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
func sunnyAt(at time.Time) port.Listen {
return port.Listen{
Track: track.Track{Source: track.NetEase, ID: "186016"},
Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269500 * time.Millisecond},
HeardAt: at,
}
}
func clipAt(at time.Time) port.Listen {
return port.Listen{
Track: track.Track{Source: track.Link, ID: "https://example.com/a.mp3"},
Meta: track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: "https://example.com/a.mp3", Duration: time.Minute},
HeardAt: at,
}
}
func songAt(id string, at time.Time) port.Listen {
return port.Listen{Track: track.Track{Source: track.NetEase, ID: id}, Meta: track.Meta{Title: id, PageURL: track.NetEasePageURL(id)}, HeardAt: at}
}
func record(t *testing.T, store *Store, members []string, l port.Listen) {
t.Helper()
if err := store.RecordListen(t.Context(), members, l); err != nil {
t.Fatal(err)
}
}
// 时间统一换成 UTC,便于直接比较。
func listensOf(t *testing.T, store *Store, memberID string) []port.Listen {
t.Helper()
listens, err := store.MemberListens(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
for i := range listens {
listens[i].HeardAt = listens[i].HeardAt.UTC()
}
return listens
}
func heardIDs(listens []port.Listen) []string {
ids := make([]string, len(listens))
for i, l := range listens {
ids[i] = l.Track.ID
}
return ids
}
func TestRecordListenKeepsLatestHearingOfEachTrack(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
record(t, store, []string{alice.ID, bob.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID}, clipAt(heard.Add(time.Minute)))
if got, want := listensOf(t, store, alice.ID), []port.Listen{clipAt(heard.Add(time.Minute)), sunnyAt(heard)}; !slices.Equal(got, want) {
t.Errorf("alice 的历史 = %#v,期望最近听到的在前 %#v", got, want)
}
if got, want := listensOf(t, store, bob.ID), []port.Listen{sunnyAt(heard)}; !slices.Equal(got, want) {
t.Errorf("bob 的历史 = %#v,期望 %#v", got, want)
}
// 同一首歌再次记录时,heard_at 取较晚的,元数据用新的。
renamed := sunnyAt(heard.Add(-time.Hour))
renamed.Meta.Title = "晴天(重制)"
record(t, store, []string{alice.ID}, renamed)
want := sunnyAt(heard)
want.Meta.Title = "晴天(重制)"
if got := listensOf(t, store, alice.ID); !slices.Equal(got, []port.Listen{clipAt(heard.Add(time.Minute)), want}) {
t.Errorf("较早的一次记录之后 = %#v,期望保留较晚的时间、换成新的元数据", got)
}
record(t, store, []string{alice.ID}, sunnyAt(heard.Add(time.Hour)))
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"186016", "https://example.com/a.mp3"}) {
t.Errorf("再次听到《晴天》之后的顺序 = %v,期望它排到最前", got)
}
}
func TestRecordListenTruncatesToStoredPrecision(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
l := sunnyAt(heard.Add(1234567 * time.Nanosecond))
l.Meta.Duration = 3*time.Minute + 1234*time.Microsecond
record(t, store, []string{alice.ID}, l)
got := listensOf(t, store, alice.ID)
if len(got) != 1 || got[0].Meta.Duration != 3*time.Minute+time.Millisecond || !got[0].HeardAt.Equal(heard.Add(1234*time.Microsecond)) {
t.Errorf("历史 = %#v,期望时长按毫秒、时间按微秒截断", got)
}
}
func TestRecordListenSkipsMembersWhoTurnedRecordingOff(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if on, err := store.ListenRecording(t.Context(), bob.ID); err != nil || !on {
t.Fatalf("新成员的 ListenRecording = %v, %v,期望默认开启", on, err)
}
record(t, store, []string{bob.ID}, sunnyAt(heard))
if err := store.SetListenRecording(t.Context(), bob.ID, false); err != nil {
t.Fatal(err)
}
if on, err := store.ListenRecording(t.Context(), bob.ID); err != nil || on {
t.Errorf("关闭之后 ListenRecording = %v, %v", on, err)
}
record(t, store, []string{alice.ID, bob.ID, "01a15778-1911-7a28-8b2e-e08c2a25f388"}, clipAt(heard.Add(time.Minute)))
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"https://example.com/a.mp3"}) {
t.Errorf("alice 的历史 = %v", got)
}
if got := heardIDs(listensOf(t, store, bob.ID)); !slices.Equal(got, []string{"186016"}) {
t.Errorf("bob 关闭记录之后的历史 = %v,期望只有关闭之前的", got)
}
if err := store.SetListenRecording(t.Context(), bob.ID, true); err != nil {
t.Fatal(err)
}
record(t, store, []string{bob.ID}, clipAt(heard.Add(2*time.Minute)))
if got := heardIDs(listensOf(t, store, bob.ID)); len(got) != 2 {
t.Errorf("重新开启之后的历史 = %v,期望继续记录", got)
}
missing := "01a15778-1911-7a28-8b2e-e08c2a25f388"
if _, err := store.ListenRecording(t.Context(), missing); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员的 ListenRecording 错误 = %v,期望 ErrMemberNotFound", err)
}
if err := store.SetListenRecording(t.Context(), missing, false); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员的 SetListenRecording 错误 = %v,期望 ErrMemberNotFound", err)
}
}
func TestRecordListenKeepsLatest500(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
// 第 i 首在 heard 之后 i 分钟听到,s0 最早。
for _, m := range []string{alice.ID, bob.ID} {
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, 499) AS i`, m, heard); err != nil {
t.Fatal(err)
}
}
record(t, store, []string{alice.ID, bob.ID}, songAt("new", heard.Add(time.Hour*24)))
for _, m := range []string{alice.ID, bob.ID} {
got := heardIDs(listensOf(t, store, m))
if len(got) != port.ListenLimit || got[0] != "new" || got[len(got)-1] != "s1" {
t.Errorf("第 501 首之后有 %d 首,最新 %q、最早 %q,期望 500 首,s0 被删掉", len(got), got[0], got[len(got)-1])
}
}
// 比现有的都早的一首,记下之后它自己就是超出的部分。
record(t, store, []string{alice.ID}, songAt("old", heard.Add(-time.Hour)))
if got := heardIDs(listensOf(t, store, alice.ID)); slices.Contains(got, "old") || len(got) != port.ListenLimit {
t.Errorf("更早的一首记下之后有 %d 首,含它 = %v,期望不留", len(got), slices.Contains(got, "old"))
}
}
// 同时开播的几个房间里有同样的两个成员,各房间给出成员的顺序不同。并发写入都成功,每首歌只留一条。
func TestConcurrentRecordsOfSameMembers(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
var wg sync.WaitGroup
errs := make(chan error, 40)
for i := range 40 {
members := []string{alice.ID, bob.ID}
if i%2 == 1 {
members = []string{bob.ID, alice.ID}
}
wg.Go(func() {
errs <- store.RecordListen(t.Context(), members, songAt(fmt.Sprint(i/2%4), heard.Add(time.Duration(i)*time.Second)))
})
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
for _, m := range []string{alice.ID, bob.ID} {
if got := heardIDs(listensOf(t, store, m)); !slices.Equal(got, []string{"3", "2", "1", "0"}) {
t.Errorf("并发记录之后的历史 = %v,期望每首一条、按最后一次听到的时间排列", got)
}
}
}
func TestDeleteAndClearListens(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
record(t, store, []string{alice.ID, bob.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID, bob.ID}, clipAt(heard.Add(time.Minute)))
if err := store.DeleteListen(t.Context(), alice.ID, sunnyAt(heard).Track); err != nil {
t.Fatal(err)
}
if err := store.DeleteListen(t.Context(), alice.ID, track.Track{Source: track.QQ, ID: "missing"}); err != nil {
t.Errorf("删除不在历史里的歌 = %v,期望忽略", err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"https://example.com/a.mp3"}) {
t.Errorf("删除《晴天》之后 alice 的历史 = %v", got)
}
if err := store.ClearListens(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if got := listensOf(t, store, alice.ID); len(got) != 0 {
t.Errorf("清空之后 alice 的历史 = %v", got)
}
if got := heardIDs(listensOf(t, store, bob.ID)); len(got) != 2 {
t.Errorf("alice 删除和清空之后 bob 的历史 = %v,期望不受影响", got)
}
if _, err := pool.Exec(t.Context(), "DELETE FROM members WHERE id = $1", bob.ID); err != nil {
t.Fatal(err)
}
var left int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM listens WHERE member_id = $1", bob.ID).Scan(&left); err != nil || left != 0 {
t.Errorf("删除成员之后还剩 %d 条历史(%v),期望随成员删除", left, err)
}
}
func TestReplaceSessionMovesGuestListensIntoAccount(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
sunny := sunnyAt(heard.Add(time.Hour))
sunny.Meta.Title = "晴天(游客听到的)"
record(t, store, []string{alice.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID}, songAt("1", heard.Add(time.Minute)))
record(t, store, []string{guest.ID}, sunny)
record(t, store, []string{guest.ID}, clipAt(heard.Add(-time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "hash"); err != nil {
t.Fatal(err)
}
if got, want := listensOf(t, store, alice.ID), []port.Listen{sunny, songAt("1", heard.Add(time.Minute)), clipAt(heard.Add(-time.Hour))}; !slices.Equal(got, want) {
t.Errorf("登录之后账号的历史 = %#v,期望并入游客的历史 %#v", got, want)
}
if got := listensOf(t, store, guest.ID); len(got) != 0 {
t.Errorf("登录之后游客的历史 = %v,期望移走", got)
}
}
func TestReplaceSessionMergeFollowsRecordingRules(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
other := createMember(t, store, "other", "游客-5e6f")
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, 499) AS i`, alice.ID, heard); err != nil {
t.Fatal(err)
}
record(t, store, []string{guest.ID}, songAt("new", heard.Add(24*time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "hash"); err != nil {
t.Fatal(err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); len(got) != port.ListenLimit || got[0] != "new" || slices.Contains(got, "s0") {
t.Errorf("并入之后账号有 %d 首,期望 500 首、最早的 s0 被删掉", len(got))
}
if err := store.SetListenRecording(t.Context(), alice.ID, false); err != nil {
t.Fatal(err)
}
record(t, store, []string{other.ID}, songAt("other", heard.Add(48*time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-3"), "hash"); err != nil {
t.Fatal(err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); slices.Contains(got, "other") {
t.Error("账号关闭记录时不应并入游客的历史")
}
if got := listensOf(t, store, other.ID); len(got) != 0 {
t.Errorf("游客的历史 = %v,账号不并入时也随会话一起删掉", got)
}
}
func TestReplaceSessionKeepsListensWhenRejected(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
record(t, store, []string{guest.ID}, sunnyAt(heard))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "changed"); !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Fatalf("密码哈希不符时 = %v", err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("nobody"), alice.ID, tokenHash("alice-2"), "hash"); !errors.Is(err, port.ErrSessionNotFound) {
t.Fatalf("旧会话不存在时 = %v", err)
}
if got := listensOf(t, store, guest.ID); len(got) != 1 {
t.Errorf("登录被拒绝之后游客的历史 = %v,期望不变", got)
}
if got := listensOf(t, store, alice.ID); len(got) != 0 {
t.Errorf("登录被拒绝之后账号的历史 = %v,期望不变", got)
}
}
// 两台设备上的游客同时登录同一个账号,两边听过同一批歌,写入的先后相反。两次合并依次进行,都成功,账号里每首歌只留一条。
func TestConcurrentReplaceSessionsMergeIntoSameAccount(t *testing.T) {
store, pool := newStore(t)
const songs = 200
for round := range 5 {
alice := registered(t, store, fmt.Sprintf("alice-%d", round))
guests := []string{fmt.Sprintf("guest-%d-a", round), fmt.Sprintf("guest-%d-b", round)}
for i, token := range guests {
guest := createMember(t, store, token, "游客-"+token)
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, $3 - 1) AS i ORDER BY i * $4`, guest.ID, heard, songs, 1-2*i); err != nil {
t.Fatal(err)
}
}
start := make(chan struct{})
errs := make([]error, len(guests))
var wg sync.WaitGroup
for i, token := range guests {
wg.Go(func() {
<-start
errs[i] = store.ReplaceSession(t.Context(), tokenHash(token), alice.ID, tokenHash(token+"-alice"), "hash")
})
}
close(start)
wg.Wait()
if err := errors.Join(errs...); err != nil {
t.Fatalf("第 %d 轮同时登录:%v", round, err)
}
got := heardIDs(listensOf(t, store, alice.ID))
if len(got) != songs || got[0] != fmt.Sprintf("s%d", songs-1) || got[songs-1] != "s0" {
t.Errorf("第 %d 轮并入之后账号有 %d 首,期望 %d 首、每首一条、按听到的时间排列", round, len(got), songs)
}
}
}
+8 -40
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -16,45 +15,16 @@ import (
var _ port.MediaLibrary = (*Store)(nil)
var mediaSorts = map[port.MediaSort]string{port.MediaByStored: "stored", port.MediaByIdle: "idle", port.MediaBySize: "size"}
// 搜索的文字按字面匹配,所以 LIKE 的通配符和转义符都要转义。
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
func (s *Store) ListStoredMedia(ctx context.Context, q port.MediaQuery) (port.MediaPage, error) {
pattern := likeEscaper.Replace(q.Title)
var page port.MediaPage
// 总数和这一页在同一个快照里读,两者才对得上。
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
qtx := s.q.WithTx(tx)
count, err := qtx.CountStoredMedia(ctx, pattern)
if err != nil {
return err
}
page.Total, page.TotalBytes = int(count.Total), count.TotalBytes
// 页码来自客户端,可能大到算偏移量时溢出,所以偏移量超过总数时直接返回,不再查询。
if int64(q.Page-1) > count.Total/port.MediaPageSize {
return nil
}
rows, err := qtx.ListStoredMedia(ctx, query.ListStoredMediaParams{
Pattern: pattern,
Sort: mediaSorts[q.Sort],
Skip: int64(q.Page-1) * port.MediaPageSize,
PageSize: port.MediaPageSize,
})
if err != nil {
return err
}
page.Items = make([]port.StoredMedia, len(rows))
for i, r := range rows {
page.Items[i] = storedMedia(query.StoredMediaRow(r))
}
return nil
})
func (s *Store) ListStoredMedia(ctx context.Context) ([]port.StoredMedia, error) {
rows, err := s.q.ListStoredMedia(ctx)
if err != nil {
return port.MediaPage{}, fmt.Errorf("读取已存储的媒体:%w", err)
return nil, fmt.Errorf("读取已存储的媒体:%w", err)
}
return page, nil
media := make([]port.StoredMedia, len(rows))
for i, r := range rows {
media[i] = storedMedia(query.StoredMediaRow(r))
}
return media, nil
}
func (s *Store) StoredMedia(ctx context.Context, key string) (port.StoredMedia, bool, error) {
@@ -81,9 +51,7 @@ func storedMedia(r query.StoredMediaRow) port.StoredMedia {
Source: track.Source(r.Source),
Link: r.TrackID,
Title: r.Title,
Artist: orEmpty(r.Artist),
Size: r.MediaSize,
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
StoredAt: r.PreparedAt.UTC(),
LastPlayedAt: utc(r.LastPlayedAt),
+12 -122
View File
@@ -1,12 +1,7 @@
package postgres
import (
"cmp"
"fmt"
"math"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -15,11 +10,10 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key string, meta track.Meta, size int64) {
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key, cover string, size int64) {
t.Helper()
meta.Title, meta.PageURL = "标题 "+key, pageURL
p := port.PreparedTrack{
Meta: meta,
Meta: track.Meta{Title: "标题 " + key, CoverKey: track.ObjectKey(cover), PageURL: pageURL},
Result: track.Prepared{Duration: time.Minute, Gain: 1},
MediaKey: key,
MediaSize: size,
@@ -33,9 +27,9 @@ func TestStoredMedia(t *testing.T) {
store, _ := newStore(t)
a := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
saveStored(t, store, a, a.ID, "aaa", track.Meta{Artist: "周杰伦", CoverKey: "link/aaa/cover.jpg"}, 100)
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", track.Meta{CoverURL: "https://example.com/b.jpg"}, 200)
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", track.Meta{Artist: "某个 UP 主", CoverKey: "link/ccc/cover.jpg"}, 300)
saveStored(t, store, a, a.ID, "aaa", "link/aaa/cover.jpg", 100)
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", "", 200)
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", "link/ccc/cover.jpg", 300)
addTrack(t, store, netease("1"), track.Meta{Title: "晴天"}, track.Prepared{Duration: time.Minute, Gain: 1})
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
@@ -43,23 +37,22 @@ func TestStoredMedia(t *testing.T) {
played := room.Entry{ID: 1, Track: a, By: alice}
saveRoom(t, store, term, snap, room.RecordPlay{Entry: played, StartedAt: t0, EndedAt: t0.Add(time.Minute)})
page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored})
list, err := store.ListStoredMedia(t.Context())
if err != nil {
t.Fatal(err)
}
list := page.Items
if len(list) != 3 || list[0].Key != "ccc" || list[1].Key != "bbb" || list[2].Key != "aaa" {
t.Fatalf("已存储的媒体 = %+v,期望两首链接和一首 B 站的歌,后存入的在前", list)
}
got := list[2]
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Artist != "周杰伦" || got.Size != 100 || got.CoverURL != "" || got.CoverKey != "link/aaa/cover.jpg" ||
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Size != 100 || got.CoverKey != "link/aaa/cover.jpg" ||
got.StoredAt.IsZero() || got.LastPlayedAt == nil || !got.LastPlayedAt.Equal(t0) {
t.Errorf("播放过的链接 = %+v", got)
}
if b := list[1]; b.Source != track.Link || b.Artist != "" || b.CoverURL != "https://example.com/b.jpg" || b.CoverKey != "" || b.LastPlayedAt != nil {
t.Errorf("没有歌手、封面不在对象存储里、没有播放过的链接 = %+v", b)
if b := list[1]; b.Source != track.Link || b.CoverKey != "" || b.LastPlayedAt != nil {
t.Errorf("没有封面、没有播放过的链接 = %+v", b)
}
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.Artist != "某个 UP 主" || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
t.Errorf("B 站的歌 = %+v", c)
}
@@ -81,110 +74,7 @@ func TestStoredMedia(t *testing.T) {
t.Errorf("清除之后 %v 的准备结果 = %v, %v,期望没有", tr, ok, err)
}
}
if page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored}); err != nil || len(page.Items) != 1 || page.Total != 1 {
t.Errorf("清除之后的列表 = %+v, %v", page, err)
}
}
func mediaKeys(items []port.StoredMedia) []string {
keys := make([]string, len(items))
for i, m := range items {
keys[i] = m.Key
}
slices.Sort(keys)
return keys
}
// 共 120 首。存储时间和最近播放时间各不相同,其中 10 首没有播放过。每 3 首大小相同,要靠曲目 ID 分出先后。
// 有 3 首的标题含 50%_off。另外两首只在 % 或 _ 被当作通配符时才会匹配 50%_off。
func TestStoredMediaPages(t *testing.T) {
store, pool := newStore(t)
titles := map[int]string{5: "Summer 50%_off", 50: "50%_OFF 特价", 95: "全场50%_Off", 20: "50abc_off", 30: "50%Xoff", 40: `C:\music`}
all := make([]port.StoredMedia, 120)
var totalBytes int64
for i := range all {
// 曲目 ID 的顺序和存入的顺序不同,这样大小相同时有没有按曲目 ID 排才看得出来。
link := fmt.Sprintf("https://example.com/%03d.mp3", i*7%120)
m := port.StoredMedia{
Key: fmt.Sprintf("key%03d", i), Source: track.Link, Link: link, Title: cmp.Or(titles[i], fmt.Sprintf("第 %d 首", i)),
Size: int64(1000 + i*11%40), StoredAt: t0.Add(time.Duration(i*13%120) * time.Minute),
}
if i%12 != 0 {
played := t0.Add(time.Duration(i*17%120) * time.Hour)
m.LastPlayedAt = &played
}
saveStored(t, store, track.Track{Source: track.Link, ID: link}, link, m.Key, track.Meta{}, m.Size)
if _, err := pool.Exec(t.Context(), "UPDATE tracks SET title = $1, prepared_at = $2, last_played_at = $3 WHERE media_key = $4", m.Title, m.StoredAt, m.LastPlayedAt, m.Key); err != nil {
t.Fatal(err)
}
all[i] = m
totalBytes += m.Size
}
list := func(q port.MediaQuery) port.MediaPage {
t.Helper()
page, err := store.ListStoredMedia(t.Context(), q)
if err != nil {
t.Fatal(err)
}
return page
}
// 每一页都报告全部的数量和大小。翻过最后一页得到的是空页。
pages := func(sort port.MediaSort) []port.StoredMedia {
t.Helper()
var items []port.StoredMedia
for i, want := range []int{50, 50, 20, 0} {
page := list(port.MediaQuery{Page: i + 1, Sort: sort})
if len(page.Items) != want || page.Total != 120 || page.TotalBytes != totalBytes {
t.Fatalf("排序 %d 的第 %d 页有 %d 首,共 %d 首 %d 字节,期望 %d 首,共 120 首 %d 字节", sort, i+1, len(page.Items), page.Total, page.TotalBytes, want, totalBytes)
}
items = append(items, page.Items...)
}
if got, want := mediaKeys(items), mediaKeys(all); !slices.Equal(got, want) {
t.Fatalf("排序 %d 依次翻页 = %q,期望不重复也不遗漏", sort, got)
}
return items
}
inOrder := func(name string, items []port.StoredMedia, compare func(a, b port.StoredMedia) int) {
t.Helper()
for i := 1; i < len(items); i++ {
if compare(items[i-1], items[i]) >= 0 {
t.Fatalf("%s:%+v 排在了 %+v 之前", name, items[i-1], items[i])
}
}
}
byLink := func(a, b port.StoredMedia) int { return strings.Compare(a.Link, b.Link) }
inOrder("按存储时间", pages(port.MediaByStored), func(a, b port.StoredMedia) int { return b.StoredAt.Compare(a.StoredAt) })
idle := pages(port.MediaByIdle)
for i, m := range idle {
if (m.LastPlayedAt == nil) != (i < 10) {
t.Fatalf("按最近播放的第 %d 首 = %+v,期望前 10 首是没有播放过的", i+1, m)
}
}
inOrder("没有播放过的", idle[:10], byLink)
inOrder("按最近播放", idle[10:], func(a, b port.StoredMedia) int { return a.LastPlayedAt.Compare(*b.LastPlayedAt) })
inOrder("按大小", pages(port.MediaBySize), func(a, b port.StoredMedia) int { return cmp.Or(cmp.Compare(b.Size, a.Size), byLink(a, b)) })
if page := list(port.MediaQuery{Page: math.MaxInt, Sort: port.MediaBySize}); len(page.Items) != 0 || page.Total != 120 || page.TotalBytes != totalBytes {
t.Errorf("页码极大时 = %d 首,共 %d 首 %d 字节,期望没有,总数照常", len(page.Items), page.Total, page.TotalBytes)
}
for _, tt := range []struct {
title string
want []int
}{
{"50%_off", []int{5, 50, 95}},
{"OFF", []int{5, 20, 30, 50, 95}},
{`:\m`, []int{40}},
} {
var want []port.StoredMedia
var bytes int64
for _, i := range tt.want {
want = append(want, all[i])
bytes += all[i].Size
}
page := list(port.MediaQuery{Page: 1, Sort: port.MediaByStored, Title: tt.title})
if got := mediaKeys(page.Items); !slices.Equal(got, mediaKeys(want)) || page.Total != len(want) || page.TotalBytes != bytes {
t.Errorf("搜索 %q = %q,共 %d 首 %d 字节,期望 %q,共 %d 首 %d 字节", tt.title, got, page.Total, page.TotalBytes, mediaKeys(want), len(want), bytes)
}
if list, err := store.ListStoredMedia(t.Context()); err != nil || len(list) != 1 {
t.Errorf("清除之后的列表 = %+v, %v", list, err)
}
}
+4 -240
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
@@ -16,20 +15,11 @@ import (
var _ port.Members = (*Store)(nil)
func (s *Store) CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error) {
var m member.Member
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
row, err := q.CreateMember(ctx, name)
if err != nil {
return err
}
m = memberOf(row)
return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: tokenHash, MemberID: row.ID})
})
row, err := s.q.CreateMember(ctx, query.CreateMemberParams{TokenHash: tokenHash, Name: name})
if err != nil {
return member.Member{}, fmt.Errorf("创建成员:%w", err)
}
return m, nil
return memberOf(row), nil
}
func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error) {
@@ -43,8 +33,8 @@ func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Mem
return memberOf(row), nil
}
func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error) {
row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash, Email: email})
func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error) {
row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash})
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
@@ -57,229 +47,3 @@ func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash, e
func memberOf(row query.Member) member.Member {
return member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)}
}
func (s *Store) AccountProfile(ctx context.Context, memberID string) (port.AccountProfile, error) {
row, err := s.q.MemberAccountProfile(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.AccountProfile{}, fmt.Errorf("读取成员 %s 的账号资料:%w", memberID, err)
}
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email), GitHub: row.GithubID != nil}, nil
}
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
n, err := q.RegisterMember(ctx, query.RegisterMemberParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
if err != nil {
return usernameError(err)
}
if n == 0 {
return port.ErrAlreadyRegistered
}
return q.CreateFavorites(ctx, memberID)
})
if err != nil {
return fmt.Errorf("注册成员 %s:%w", memberID, err)
}
return nil
}
func (s *Store) AddCredentials(ctx context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
// UPDATE 先锁住成员行,再用新的语句删除会话。这样要等同时在用 GitHub 登录的设备提交之后才开始删,那台设备登录时签发的会话也会被删掉。
n, err := q.AddMemberCredentials(ctx, query.AddMemberCredentialsParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
if err != nil {
return usernameError(err)
}
if n == 0 {
return port.ErrAlreadyRegistered
}
deleted, err = q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
return err
})
if err != nil {
return nil, fmt.Errorf("给成员 %s 补设用户名和密码:%w", memberID, err)
}
return deleted, nil
}
func usernameError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_username_key" {
return port.ErrUsernameTaken
}
return err
}
func (s *Store) AccountByUsername(ctx context.Context, username string) (port.Account, error) {
row, err := s.q.AccountByUsername(ctx, &username)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("按用户名查找成员:%w", err)
}
return accountOf(row), nil
}
func (s *Store) AccountByID(ctx context.Context, memberID string) (port.Account, error) {
row, err := s.q.AccountByID(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("读取成员 %s 的账号:%w", memberID, err)
}
return accountOf(row), nil
}
func (s *Store) AccountByGitHub(ctx context.Context, githubID int64) (port.Account, error) {
row, err := s.q.AccountByGitHub(ctx, &githubID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("按 GitHub 账号查找成员:%w", err)
}
return accountOf(row), nil
}
func accountOf(row query.Member) port.Account {
account := port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}
if row.GithubID != nil {
account.GitHubID = *row.GithubID
}
return account
}
func (s *Store) BindGitHub(ctx context.Context, memberID string, githubID int64) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
wasGuest, err := q.BindGitHub(ctx, query.BindGitHubParams{ID: memberID, GithubID: &githubID})
var pgErr *pgconn.PgError
switch {
case errors.Is(err, pgx.ErrNoRows):
return port.ErrGitHubAlreadyBound
case errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_github_id_key":
return port.ErrGitHubTaken
case err != nil:
return err
case !wasGuest:
return nil
}
return q.CreateFavorites(ctx, memberID)
})
if err != nil {
return fmt.Errorf("给成员 %s 绑定 GitHub:%w", memberID, err)
}
return nil
}
func (s *Store) UnbindGitHub(ctx context.Context, memberID string) error {
n, err := s.q.UnbindGitHub(ctx, memberID)
if err == nil && n == 0 {
err = port.ErrNoPassword
}
if err != nil {
return fmt.Errorf("给成员 %s 解绑 GitHub:%w", memberID, err)
}
return nil
}
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
current, err := q.LockMemberPassword(ctx, memberID)
if err != nil {
return err
}
if current != passwordHash {
return port.ErrAccountPasswordChanged
}
previous, err := q.TakeSession(ctx, oldTokenHash)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrSessionNotFound
}
if err != nil {
return err
}
if err := q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: memberID}); err != nil {
return err
}
if previous == memberID {
return nil
}
return moveListens(ctx, q, previous, memberID)
})
if err != nil {
return fmt.Errorf("给成员 %s 更换会话:%w", memberID, err)
}
return nil
}
func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error {
if _, err := s.q.DeleteSession(ctx, tokenHash); err != nil {
return fmt.Errorf("删除会话:%w", err)
}
return nil
}
func (s *Store) SetPassword(ctx context.Context, memberID, passwordHash string) ([][]byte, error) {
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
var err error
deleted, err = setPassword(ctx, s.q.WithTx(tx), memberID, passwordHash, nil)
return err
})
if err != nil {
return nil, fmt.Errorf("修改成员 %s 的密码:%w", memberID, err)
}
return deleted, nil
}
func (s *Store) ChangePassword(ctx context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
// 先锁住成员行,再用新的语句查会话。这样要等并发的修改或重置提交之后才开始查,才能看到那次修改或重置删掉的会话。
current, err := q.LockAccountPassword(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrMemberNotFound
}
if err != nil {
return err
}
kept, err := q.MemberHasSession(ctx, query.MemberHasSessionParams{TokenHash: keepTokenHash, MemberID: memberID})
if err != nil {
return err
}
if !kept {
return port.ErrSessionNotFound
}
if current != currentPasswordHash {
return port.ErrAccountPasswordChanged
}
deleted, err = setPassword(ctx, q, memberID, passwordHash, keepTokenHash)
return err
})
if err != nil {
return nil, fmt.Errorf("修改成员 %s 的密码:%w", memberID, err)
}
return deleted, nil
}
func setPassword(ctx context.Context, q *query.Queries, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
n, err := q.SetMemberPassword(ctx, query.SetMemberPasswordParams{ID: memberID, PasswordHash: &passwordHash})
if err != nil {
return nil, err
}
if n == 0 {
return nil, port.ErrMemberNotFound
}
return q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
}
+5 -522
View File
@@ -5,9 +5,6 @@ import (
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
@@ -62,13 +59,13 @@ func TestMemberAvatarIsNullWhenUnset(t *testing.T) {
if hash := avatar(); hash != "NULL" {
t.Errorf("新成员的 avatar_hash = %q,期望 NULL", hash)
}
if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil); err != nil {
if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346")); err != nil {
t.Fatal(err)
}
if hash := avatar(); hash != "0bc83cb571cd1c50ba6f3e8a78ef1346" {
t.Errorf("设置头像之后 avatar_hash = %q", hash)
}
got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""), nil)
got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""))
if err != nil {
t.Fatal(err)
}
@@ -81,14 +78,14 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
store, _ := newStore(t)
m := createMember(t, store, "alice", "游客-1a2b")
got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil, nil)
got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil)
if err != nil {
t.Fatal(err)
}
if want := (member.Member{ID: m.ID, Name: "甲"}); got != want {
t.Errorf("只改昵称 = %#v,期望 %#v", got, want)
}
got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil)
got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"))
if err != nil {
t.Fatal(err)
}
@@ -99,521 +96,7 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
t.Errorf("修改后按 token 查到 %#v,期望 %#v", found, got)
}
if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil, nil); !errors.Is(err, port.ErrMemberNotFound) {
if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("修改不存在的成员,错误 = %v,期望 ErrMemberNotFound", err)
}
}
func TestUpdateMemberEmail(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
guest := createMember(t, store, "guest", "游客-3c4d")
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil {
t.Fatal(err)
}
column := func(id string) string {
t.Helper()
var email string
if err := pool.QueryRow(t.Context(), "SELECT coalesce(email, 'NULL') FROM members WHERE id = $1", id).Scan(&email); err != nil {
t.Fatal(err)
}
return email
}
profile := func() port.AccountProfile {
t.Helper()
p, err := store.AccountProfile(t.Context(), alice.ID)
if err != nil {
t.Fatal(err)
}
return p
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("Alice@Example.COM")); err != nil {
t.Fatal(err)
}
if got := profile(); got != (port.AccountProfile{Username: "alice", Email: "Alice@Example.COM"}) {
t.Errorf("保存邮箱之后 AccountProfile = %#v", got)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.Email != "Alice@Example.COM" {
t.Errorf("AccountByUsername 的邮箱 = %q, %v", account.Email, err)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, ptr("甲"), nil, nil); err != nil {
t.Fatal(err)
}
if got := profile().Email; got != "Alice@Example.COM" {
t.Errorf("邮箱参数为 nil 时应保持原值,得到 %q", got)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("b@example.com")); err != nil {
t.Fatal(err)
}
if got := profile().Email; got != "b@example.com" {
t.Errorf("更新邮箱之后 = %q", got)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("")); err != nil {
t.Fatal(err)
}
if got := column(alice.ID); got != "NULL" {
t.Errorf("清除邮箱之后 email 列 = %q,期望 NULL", got)
}
if _, err := store.UpdateMember(t.Context(), guest.ID, nil, nil, ptr("g@example.com")); err != nil {
t.Fatal(err)
}
if got := column(guest.ID); got != "NULL" {
t.Errorf("游客传入邮箱之后 email 列 = %q,期望 NULL", got)
}
if p, err := store.AccountProfile(t.Context(), guest.ID); err != nil || p != (port.AccountProfile{}) {
t.Errorf("游客的 AccountProfile = %#v, %v", p, err)
}
}
func ownerOf(t *testing.T, store *Store, token string) string {
t.Helper()
m, err := store.MemberByToken(t.Context(), tokenHash(token))
if errors.Is(err, port.ErrMemberNotFound) {
return ""
}
if err != nil {
t.Fatal(err)
}
return m.ID
}
func TestRegisterMember(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if profile, err := store.AccountProfile(t.Context(), alice.ID); err != nil || profile != (port.AccountProfile{}) {
t.Fatalf("游客的 AccountProfile = %#v, %v,期望两个字段都为空", profile, err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil {
t.Fatal(err)
}
if profile, err := store.AccountProfile(t.Context(), alice.ID); err != nil || profile != (port.AccountProfile{Username: "alice"}) {
t.Errorf("注册之后的 AccountProfile = %#v, %v,期望只有用户名 alice", profile, err)
}
account, err := store.AccountByUsername(t.Context(), "alice")
if want := (port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a"}); err != nil || account != want {
t.Errorf("AccountByUsername = %#v, %v,期望 %#v", account, err, want)
}
if err := store.Register(t.Context(), bob.ID, "alice", "hash-b"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名重复时 = %v,期望 ErrUsernameTaken", err)
}
if err := store.Register(t.Context(), alice.ID, "alice2", "hash-c"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("已经注册过的成员再注册 = %v,期望 ErrAlreadyRegistered", err)
}
if _, err := store.AccountByUsername(t.Context(), "nobody"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的用户名 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.AccountProfile(t.Context(), "00000000-0000-7000-8000-000000000000"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员 = %v,期望 ErrMemberNotFound", err)
}
}
func TestMemberSessions(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
createMember(t, store, "guest-1", "游客-3c4d")
createMember(t, store, "guest-2", "游客-5e6f")
for _, s := range []struct{ guest, token string }{{"guest-1", "alice-2"}, {"guest-2", "alice-3"}} {
if err := store.ReplaceSession(t.Context(), tokenHash(s.guest), alice.ID, tokenHash(s.token), ""); err != nil {
t.Fatal(err)
}
if got := ownerOf(t, store, s.guest); got != "" {
t.Errorf("换掉之后 %s 仍属于成员 %s", s.guest, got)
}
if got := ownerOf(t, store, s.token); got != alice.ID {
t.Errorf("%s 属于成员 %q,期望 %s", s.token, got, alice.ID)
}
}
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
}
deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1"))
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice-3") != "" {
t.Error("ChangePassword 应只留下发出请求的会话")
}
if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil {
t.Fatal(err)
}
if got := ownerOf(t, store, "alice-1"); got != "" {
t.Errorf("删除之后 alice-1 仍属于成员 %s", got)
}
}
func TestSetPassword(t *testing.T) {
store, _ := newStore(t)
guest := createMember(t, store, "guest", "游客-1a2b")
if _, err := store.SetPassword(t.Context(), guest.ID, "hash"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("给游客设置密码 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.ChangePassword(t.Context(), guest.ID, "", "hash", tokenHash("guest")); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("游客修改密码 = %v,期望 ErrMemberNotFound", err)
}
alice := createMember(t, store, "alice-1", "游客-3c4d")
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
}
createMember(t, store, "other", "游客-5e6f")
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2"), "old"); err != nil {
t.Fatal(err)
}
if deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1")); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2", deleted, err)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "new" {
t.Errorf("修改之后的密码哈希 = %q, %v,期望 new", account.PasswordHash, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" {
t.Error("修改密码应只留下指定的会话")
}
if deleted, err := store.SetPassword(t.Context(), alice.ID, "newer"); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-1")}) {
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-1", deleted, err)
}
if got := ownerOf(t, store, "alice-1"); got != "" {
t.Errorf("SetPassword 应删除全部会话,alice-1 仍属于 %s", got)
}
}
func TestChangePasswordChecksSessionAndPassword(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
createMember(t, store, "bob", "游客-5e6f")
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, current, keep string
want error
}{
{"发出请求的会话已经不存在", "h1", "gone", port.ErrSessionNotFound},
{"会话属于别的成员", "h1", "bob", port.ErrSessionNotFound},
{"密码哈希已经变了", "h0", "alice-1", port.ErrAccountPasswordChanged},
} {
if _, err := store.ChangePassword(t.Context(), alice.ID, tc.current, "h2", tokenHash(tc.keep)); !errors.Is(err, tc.want) {
t.Errorf("%s时 = %v,期望 %v", tc.name, err, tc.want)
}
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "h1" {
t.Errorf("失败之后的密码哈希 = %q, %v,期望不变的 h1", account.PasswordHash, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != alice.ID {
t.Error("失败时不应删除任何会话")
}
}
// 模拟 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())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "UPDATE members SET password_hash = 'h2' WHERE id = $1", memberID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE member_id = $1 AND token_hash IS DISTINCT FROM $2", memberID, keep); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
_, err := store.ChangePassword(t.Context(), memberID, "h1", "h3", tokenHash("alice-1"))
result <- err
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if err := <-result; !errors.Is(err, want) {
t.Errorf("另一次修改提交之后,ChangePassword = %v,期望 %v", err, want)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "h2" {
t.Errorf("密码哈希 = %q, %v,期望先提交的那次写入的 h2", account.PasswordHash, err)
}
}
func TestChangePasswordWaitsForReset(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
changePasswordMeanwhile(t, store, pool, alice.ID, nil, port.ErrSessionNotFound)
}
func TestChangePasswordWaitsForSameSessionChange(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
changePasswordMeanwhile(t, store, pool, alice.ID, tokenHash("alice-1"), port.ErrAccountPasswordChanged)
if ownerOf(t, store, "alice-1") != alice.ID {
t.Error("同一个会话先改过密码,这个会话应仍然有效")
}
}
func TestReplaceSessionChecksGuestSessionAndPassword(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
if err := store.ReplaceSession(t.Context(), tokenHash("gone"), alice.ID, tokenHash("alice-2"), "h1"); !errors.Is(err, port.ErrSessionNotFound) {
t.Errorf("游客的会话已经删除时 = %v,期望 ErrSessionNotFound", err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h0"); !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Errorf("密码哈希已经变了时 = %v,期望 ErrAccountPasswordChanged", err)
}
if ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "guest") == "" {
t.Error("失败时不应创建新会话,也不应删除游客的会话")
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1"); err != nil {
t.Fatal(err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-3"), "h1"); !errors.Is(err, port.ErrSessionNotFound) {
t.Errorf("同一个游客会话再登录一次 = %v,期望 ErrSessionNotFound", err)
}
if ownerOf(t, store, "alice-3") != "" {
t.Error("同一个游客会话只能换出一个新会话")
}
}
func TestReplaceSessionWaitsForPasswordChange(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
// 模拟 SetPassword 的事务,先不提交。
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "UPDATE members SET password_hash = 'h2' WHERE id = $1", alice.ID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE member_id = $1", alice.ID); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
result <- store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1")
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if err := <-result; !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Errorf("改密码提交之后,登录的错误 = %v,期望 ErrAccountPasswordChanged", err)
}
if got := ownerOf(t, store, "alice-2"); got != "" {
t.Errorf("改密码之后还留下了按旧密码签发的会话,属于 %s", got)
}
}
func favoritesCount(t *testing.T, store *Store, memberID string) int {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
n := 0
for _, l := range lists {
if l.Favorites {
n++
}
}
return n
}
func TestBindGitHub(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{GitHub: true}) || !p.Registered() {
t.Errorf("绑定之后的 AccountProfile = %#v, %v,期望只绑定了 GitHub 的已注册账号", p, err)
}
want := port.Account{Member: alice, GitHubID: 42}
if got, err := store.AccountByGitHub(t.Context(), 42); err != nil || got != want {
t.Errorf("AccountByGitHub = %#v, %v,期望 %#v", got, err, want)
}
if got, err := store.AccountByID(t.Context(), alice.ID); err != nil || got != want {
t.Errorf("AccountByID = %#v, %v,期望 %#v", got, err, want)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("游客绑定 GitHub 就是注册,应创建一个“我喜欢”,实际 %d 个", n)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); !errors.Is(err, port.ErrGitHubTaken) {
t.Errorf("GitHub 账号已经绑定了别人 = %v,期望 ErrGitHubTaken", err)
}
if err := store.BindGitHub(t.Context(), alice.ID, 43); !errors.Is(err, port.ErrGitHubAlreadyBound) {
t.Errorf("已经绑定过的成员再绑定 = %v,期望 ErrGitHubAlreadyBound", err)
}
if p, err := store.AccountProfile(t.Context(), bob.ID); err != nil || p.Registered() || favoritesCount(t, store, bob.ID) != 0 {
t.Errorf("绑定失败之后 bob 的 AccountProfile = %#v, %v,应仍是游客", p, err)
}
if _, err := store.AccountByGitHub(t.Context(), 99); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("没有绑定的 GitHub 账号 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.AccountByID(t.Context(), bob.ID); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("游客的 AccountByID = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("alice@example.com")); err != nil {
t.Fatal(err)
}
var email string
if err := pool.QueryRow(t.Context(), "SELECT coalesce(email, 'NULL') FROM members WHERE id = $1", alice.ID).Scan(&email); err != nil || email != "alice@example.com" {
t.Errorf("只绑定了 GitHub 的账号保存邮箱之后 email 列 = %q, %v", email, err)
}
}
func TestAddCredentialsAfterGitHub(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("只绑定了 GitHub 的账号用 Register 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
guest := createMember(t, store, "guest", "游客-5e6f")
if _, err := store.AddCredentials(t.Context(), guest.ID, "guest", "hash-g", tokenHash("guest")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("游客用 AddCredentials 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
if err := store.Register(t.Context(), guest.ID, "taken", "hash-g"); err != nil {
t.Fatal(err)
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "taken", "hash-a", tokenHash("alice")); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
createMember(t, store, "other", "游客-7a8b")
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2"), ""); err != nil {
t.Fatal(err)
}
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
if err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
t.Fatalf("AddCredentials 返回 %x, %v,期望被删除的 alice-2", deleted, err)
}
if ownerOf(t, store, "alice") != alice.ID || ownerOf(t, store, "alice-2") != "" {
t.Error("AddCredentials 应只留下发出请求的会话")
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "alice2", "hash-c", tokenHash("alice")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("再补设一次 = %v,期望 ErrAlreadyRegistered", err)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("只绑定了 GitHub 的账号补设用户名之后有 %d 个“我喜欢”,期望仍是 1 个", n)
}
want := port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a", GitHubID: 42}
if got, err := store.AccountByUsername(t.Context(), "alice"); err != nil || got != want {
t.Errorf("AccountByUsername = %#v, %v,期望 %#v", got, err, want)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{Username: "alice"}) {
t.Errorf("解绑之后的 AccountProfile = %#v, %v", p, err)
}
if _, err := store.AccountByGitHub(t.Context(), 42); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("解绑之后按 GitHub 查找 = %v,期望 ErrMemberNotFound", err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Errorf("没有绑定时解绑 = %v,期望什么也不做", err)
}
bob := createMember(t, store, "bob", "游客-3c4d")
if err := store.Register(t.Context(), bob.ID, "bob", "hash-b"); err != nil {
t.Fatal(err)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); err != nil {
t.Fatal(err)
}
if n := favoritesCount(t, store, bob.ID); n != 1 {
t.Errorf("已注册的账号绑定 GitHub 之后有 %d 个“我喜欢”,期望仍是 1 个", n)
}
}
func TestOnlyAccountsHavePasswordOrEmail(t *testing.T) {
store, pool := newStore(t)
guest := createMember(t, store, "guest", "游客-1a2b")
for _, c := range []struct{ set, constraint string }{
{"password_hash = 'hash'", "members_password_check"},
{"email = 'guest@example.com'", "members_email_check"},
} {
_, err := pool.Exec(t.Context(), "UPDATE members SET "+c.set+" WHERE id = $1", guest.ID)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23514" || pgErr.ConstraintName != c.constraint {
t.Errorf("给没有用户名的游客设置 %s,错误 = %v,期望违反 %s", c.set, err, c.constraint)
}
}
}
// 补设用户名和密码时,用 GitHub 登录的另一台设备恰好在签发会话。补设要等那台设备的事务提交,再把它签发的会话一并删除。
func TestAddCredentialsWaitsForGitHubLogin(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
// 模拟 ReplaceSession 的事务,先不提交。
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "SELECT 1 FROM members WHERE id = $1 FOR NO KEY UPDATE", alice.ID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE token_hash = $1", tokenHash("guest")); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)", tokenHash("alice-2"), alice.ID); err != nil {
t.Fatal(err)
}
type result struct {
deleted [][]byte
err error
}
done := make(chan result, 1)
go func() {
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
done <- result{deleted, err}
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if r := <-done; r.err != nil || !sameHashes(r.deleted, [][]byte{tokenHash("alice-2")}) {
t.Errorf("登录提交之后,AddCredentials 返回 %x, %v,期望删除登录签发的 alice-2", r.deleted, r.err)
}
if ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice") != alice.ID {
t.Error("补设之后应只留下发出请求的会话")
}
}
+3 -3
View File
@@ -17,10 +17,10 @@ import (
const migrationLock int64 = 0x616c697374656e
// 锁所在的事务一直占着一个连接,所以 fn 里的迁移要用连接池里的其他连接。
// 锁所在的事务一直占用一个连接,fn 中的迁移要使用连接池中的其他连接。
func WithMigrationLock(ctx context.Context, pool *pgxpool.Pool, fn func(context.Context) error) error {
return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
// 这个事务在整个迁移期间都处于空闲状态,数据库设置的空闲事务超时或事务超时会断开这个事务,锁也就随之失效。
// 这个事务在整个迁移期间都处于空闲状态,数据库设置的空闲事务超时或事务超时会断开它,锁随之失效。
// 后到的迁移要一直等待锁,而数据库设置的 lock_timeout、statement_timeout 和 transaction_timeout 会让它报错而不是等待。
if _, err := tx.Exec(ctx, `SELECT set_config('idle_in_transaction_session_timeout', '0', true),
set_config('lock_timeout', '0', true), set_config('statement_timeout', '0', true),
@@ -52,7 +52,7 @@ func MigrateSchema(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger)
}
func MigrateRiver(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error {
// rivermigrate 的日志带有 version 字段,而 slog 会把重名的字段写两次。放进 river 分组,rivermigrate 的 version 才不会和日志的固定字段 version 重名。
// rivermigrate 的日志带有 version 字段,而 slog 会把重名的字段写两次:放进 river 分组,它才不会与日志的固定字段 version 重名。
migrator, err := rivermigrate.New(riverpgxv5.New(pool), &rivermigrate.Config{Logger: logger.WithGroup("river")})
if err != nil {
return fmt.Errorf("创建 River 迁移器:%w", err)
+1 -98
View File
@@ -11,11 +11,6 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"github.com/dihemoe/alisten/db/migrations"
"github.com/dihemoe/alisten/internal/app/port"
)
func migrate(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error {
@@ -66,7 +61,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)
}
@@ -133,95 +128,3 @@ func TestMigrationLogsKeepFixedFields(t *testing.T) {
}
}
}
func TestMemberSessionsMigrationKeepsMemberTokens(t *testing.T) {
ctx := t.Context()
name := fmt.Sprintf("test_%d", databases.Add(1))
if _, err := admin.Exec(ctx, "CREATE DATABASE "+name); err != nil {
t.Fatal(err)
}
cfg := server.Copy()
cfg.ConnConfig.Database = name
pool := connect(t, cfg)
db := stdlib.OpenDBFromPool(pool)
t.Cleanup(func() { _ = db.Close() })
provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS)
if err != nil {
t.Fatal(err)
}
if _, err := provider.UpTo(ctx, 2); err != nil {
t.Fatal(err)
}
created := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
var guest, bot string
if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name, created_at) VALUES ($1, '游客-1a2b', $2) RETURNING id", tokenHash("guest"), created).Scan(&guest); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name) VALUES ($1, 'bot') RETURNING id", tokenHash("bot")).Scan(&bot); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "INSERT INTO rooms (kind, name, description, bot_member_id) VALUES ('persistent', '大厅', '', $1)", bot); err != nil {
t.Fatal(err)
}
if _, err := provider.Up(ctx); err != nil {
t.Fatal(err)
}
store := New(pool)
if m, err := store.MemberByToken(ctx, tokenHash("guest")); err != nil || m.ID != guest {
t.Errorf("迁移之后按原来的 token 找到 %#v, %v,期望成员 %s", m, err, guest)
}
var sessionCreated time.Time
if err := pool.QueryRow(ctx, "SELECT created_at FROM member_sessions WHERE member_id = $1", guest).Scan(&sessionCreated); err != nil || !sessionCreated.Equal(created) {
t.Errorf("会话的创建时间 = %v, %v,期望沿用成员的创建时间 %v", sessionCreated, err, created)
}
if _, err := store.MemberByToken(ctx, tokenHash("bot")); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("bot 成员原来的 token 在迁移之后 = %v,期望不再有效", err)
}
}
func TestTicketSessionsMigrationBindsTicketsToSessions(t *testing.T) {
ctx := t.Context()
name := fmt.Sprintf("test_%d", databases.Add(1))
if _, err := admin.Exec(ctx, "CREATE DATABASE "+name); err != nil {
t.Fatal(err)
}
cfg := server.Copy()
cfg.ConnConfig.Database = name
pool := connect(t, cfg)
db := stdlib.OpenDBFromPool(pool)
t.Cleanup(func() { _ = db.Close() })
provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS)
if err != nil {
t.Fatal(err)
}
if _, err := provider.UpTo(ctx, 2); err != nil {
t.Fatal(err)
}
var guest, bot, roomID string
if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name) VALUES ($1, '游客-1a2b') RETURNING id", tokenHash("guest")).Scan(&guest); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name) VALUES ($1, 'bot') RETURNING id", tokenHash("bot")).Scan(&bot); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "INSERT INTO rooms (kind, name, description, bot_member_id) VALUES ('persistent', '大厅', '', $1) RETURNING id", bot).Scan(&roomID); err != nil {
t.Fatal(err)
}
for ticket, memberID := range map[string]string{"guest-ticket": guest, "api-key": bot} {
if _, err := pool.Exec(ctx, "INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES ($1, $2, $3)", tokenHash(ticket), roomID, memberID); err != nil {
t.Fatal(err)
}
}
if _, err := provider.Up(ctx); err != nil {
t.Fatal(err)
}
store := New(pool)
if holder, err := store.TicketHolder(ctx, tokenHash("guest-ticket")); err != nil || !bytes.Equal(holder.SessionTokenHash, tokenHash("guest")) {
t.Errorf("游客的票据 = %#v, %v,期望归到游客的会话", holder, err)
}
if holder, err := store.TicketHolder(ctx, tokenHash("api-key")); err != nil || !holder.Bot || holder.SessionTokenHash != nil {
t.Errorf("API Key 的票据 = %#v, %v,期望保留,没有会话", holder, err)
}
}
-449
View File
@@ -1,449 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"slices"
"time"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/track"
)
var _ port.Playlists = (*Store)(nil)
const favoritesKind = "favorites"
func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.PlaylistRecord, error) {
rows, err := s.q.MemberPlaylists(ctx, ownerID)
if err != nil {
return nil, fmt.Errorf("读取成员 %s 的歌单:%w", ownerID, err)
}
coverRows, err := s.q.MemberPlaylistCovers(ctx, ownerID)
if err != nil {
return nil, fmt.Errorf("读取成员 %s 的歌单封面:%w", ownerID, err)
}
covers := make(map[string]*port.Cover, len(coverRows))
for _, c := range coverRows {
covers[c.PlaylistID] = &port.Cover{URL: orEmpty(c.CoverURL), Key: track.ObjectKey(orEmpty(c.CoverKey))}
}
lists := make([]port.PlaylistRecord, len(rows))
for i, r := range rows {
lists[i] = port.PlaylistRecord{
ID: r.ID,
Name: r.Name,
Favorites: r.Kind == favoritesKind,
TrackCount: int(r.TrackCount),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
Cover: covers[r.ID],
ShareID: orEmpty(r.ShareID),
}
}
return lists, nil
}
func (s *Store) CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (port.PlaylistRecord, error) {
var created query.CreatePlaylistRow
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
created, err = createPlaylist(ctx, s.q.WithTx(tx), ownerID, name, limit)
return err
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 新建歌单:%w", ownerID, err)
}
return port.PlaylistRecord{ID: created.ID, Name: name, CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}, nil
}
func (s *Store) ImportPlaylist(ctx context.Context, ownerID, name string, items []port.PlaylistItem, limit int) (port.PlaylistRecord, error) {
var created query.CreatePlaylistRow
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
q := s.q.WithTx(tx)
if created, err = createPlaylist(ctx, q, ownerID, name, limit); err != nil {
return err
}
seq := int64(len(items))
rows := make([]query.InsertPlaylistItemsParams, len(items))
for i, item := range items {
t, m := item.Track, item.Meta
rows[i] = query.InsertPlaylistItemsParams{
PlaylistID: created.ID,
Source: string(t.Source),
TrackID: t.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
Position: seq - int64(i),
}
}
if _, err = q.InsertPlaylistItems(ctx, rows); err != nil {
return err
}
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: seq})
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 导入 %d 首歌到新歌单:%w", ownerID, len(items), err)
}
return port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: len(items), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt, Cover: firstCover(items)}, nil
}
// 要在事务里调用,这样“我喜欢”的行锁会持续到新建之后,同一个账号并发新建时才会依次计数。
func createPlaylist(ctx context.Context, q *query.Queries, ownerID, name string, limit int) (query.CreatePlaylistRow, error) {
if err := q.LockFavorites(ctx, ownerID); err != nil {
return query.CreatePlaylistRow{}, err
}
n, err := q.CountPlaylists(ctx, ownerID)
if err != nil {
return query.CreatePlaylistRow{}, err
}
if n >= int64(limit) {
return query.CreatePlaylistRow{}, port.ErrPlaylistLimit
}
return q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
}
func firstCover(items []port.PlaylistItem) *port.Cover {
for _, item := range items {
if m := item.Meta; m.CoverURL != "" || m.CoverKey != "" {
return &port.Cover{URL: m.CoverURL, Key: m.CoverKey}
}
}
return nil
}
func (s *Store) RenamePlaylist(ctx context.Context, ownerID, id, name string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.RenamePlaylist(ctx, query.RenamePlaylistParams{ID: id, OwnerID: ownerID, Name: name})
if err == nil && n == 0 {
err = s.unchangeable(ctx, ownerID, id)
}
if err != nil {
return fmt.Errorf("歌单 %s 改名:%w", id, err)
}
return nil
}
func (s *Store) DeletePlaylist(ctx context.Context, ownerID, id string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.DeletePlaylist(ctx, query.DeletePlaylistParams{ID: id, OwnerID: ownerID})
if err == nil && n == 0 {
err = s.unchangeable(ctx, ownerID, id)
}
if err != nil {
return fmt.Errorf("删除歌单 %s:%w", id, err)
}
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 {
case errors.Is(err, pgx.ErrNoRows):
return port.ErrPlaylistNotFound
case err != nil:
return err
case kind == favoritesKind:
return port.ErrFavoritesFixed
}
return port.ErrPlaylistNotFound
}
func (s *Store) owned(ctx context.Context, ownerID, id string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
_, err := s.q.PlaylistKind(ctx, query.PlaylistKindParams{ID: id, OwnerID: ownerID})
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
}
return err
}
func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.PlaylistItem, error) {
if err := s.owned(ctx, ownerID, id); err != nil {
return nil, fmt.Errorf("读取歌单 %s:%w", id, err)
}
rows, err := s.q.PlaylistItems(ctx, id)
if err != nil {
return nil, fmt.Errorf("读取歌单 %s 的曲目:%w", id, err)
}
return playlistItems(rows), nil
}
func playlistItems(rows []query.PlaylistItem) []port.PlaylistItem {
items := make([]port.PlaylistItem, len(rows))
for i, r := range rows {
items[i] = port.PlaylistItem{
Track: track.Track{Source: track.Source(r.Source), ID: r.TrackID},
Meta: track.Meta{
Title: r.Title,
Artist: orEmpty(r.Artist),
Album: orEmpty(r.Album),
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
PageURL: r.PageURL,
Duration: time.Duration(r.DurationMs) * time.Millisecond,
},
}
}
return items
}
func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
t, m := item.Track, item.Meta
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
seq, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: id, OwnerID: ownerID})
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
}
if err != nil {
return err
}
has, err := q.PlaylistHasItem(ctx, query.PlaylistHasItemParams{PlaylistID: id, Source: string(t.Source), TrackID: t.ID})
if err != nil || has {
return err
}
n, err := q.CountPlaylistItems(ctx, id)
if err != nil {
return err
}
if n >= int64(limit) {
return port.ErrPlaylistLimit
}
seq++
if err := q.InsertPlaylistItem(ctx, query.InsertPlaylistItemParams{
PlaylistID: id,
Source: string(t.Source),
TrackID: t.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
Position: seq,
}); err != nil {
return err
}
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: id, ItemSeq: seq})
})
if err != nil {
return fmt.Errorf("向歌单 %s 加入 %s/%s:%w", id, t.Source, t.ID, err)
}
return nil
}
func (s *Store) RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
if _, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: id, OwnerID: ownerID}); errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
} else if err != nil {
return err
}
sources, ids := splitTracks(tracks)
return q.DeletePlaylistItems(ctx, query.DeletePlaylistItemsParams{PlaylistID: id, Sources: sources, TrackIds: ids})
})
if err != nil {
return fmt.Errorf("从歌单 %s 移除 %d 首歌:%w", id, len(tracks), err)
}
return nil
}
func (s *Store) TrackPlaylists(ctx context.Context, ownerID string, t track.Track) ([]string, error) {
ids, err := s.q.TrackPlaylists(ctx, query.TrackPlaylistsParams{OwnerID: ownerID, Source: string(t.Source), TrackID: t.ID})
if err != nil {
return nil, fmt.Errorf("查询曲目 %s 所在的歌单:%w", t.ID, err)
}
if ids == nil {
ids = []string{}
}
return ids, nil
}
func (s *Store) CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) {
var added int64
err := s.owned(ctx, ownerID, fromID)
if err == nil && !isUUID(toID) {
err = port.ErrPlaylistNotFound
}
if err == nil {
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
seq, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: toID, OwnerID: ownerID})
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
}
if err != nil {
return err
}
sources, ids := splitTracks(tracks)
added, err = q.CopyPlaylistItems(ctx, query.CopyPlaylistItemsParams{FromID: fromID, ToID: toID, ItemSeq: seq, Sources: sources, TrackIds: ids})
if err != nil || added == 0 {
return err
}
n, err := q.CountPlaylistItems(ctx, toID)
if err != nil {
return err
}
if n > int64(limit) {
return port.ErrPlaylistLimit
}
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: toID, ItemSeq: seq + added})
})
}
if err != nil {
return 0, fmt.Errorf("从歌单 %s 向歌单 %s 复制 %d 首歌:%w", fromID, toID, len(tracks), err)
}
return int(added), nil
}
func (s *Store) MovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track, before *track.Track) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
if _, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: id, OwnerID: ownerID}); errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
} else if err != nil {
return err
}
rows, err := q.PlaylistTracks(ctx, id)
if err != nil {
return err
}
order := make([]track.Track, len(rows))
for i, r := range rows {
order[i] = track.Track{Source: track.Source(r.Source), ID: r.TrackID}
}
moved, err := playlist.Move(order, tracks, before)
if err != nil || slices.Equal(moved, order) {
return err
}
// 显示顺序里最靠前的歌取最大的排序值。歌单的 item_seq 不小于曲目数,所以新加入的歌仍然排在最前。
positions := make([]int64, len(moved))
for i := range moved {
positions[i] = int64(len(moved) - i)
}
sources, ids := splitTracks(moved)
if err := q.SetPlaylistPositions(ctx, query.SetPlaylistPositionsParams{PlaylistID: id, Sources: sources, TrackIds: ids, Positions: positions}); err != nil {
return err
}
return q.TouchPlaylist(ctx, id)
})
if err != nil {
return fmt.Errorf("调整歌单 %s 的顺序:%w", id, err)
}
return nil
}
func (s *Store) SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.SetPlaylistShare(ctx, query.SetPlaylistShareParams{ID: id, OwnerID: ownerID, ShareID: nullable(shareID)})
if err == nil && n == 0 {
err = port.ErrPlaylistNotFound
}
if err != nil {
return fmt.Errorf("设置歌单 %s 的分享:%w", id, err)
}
return nil
}
func (s *Store) SharedPlaylist(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
var shared port.SharedPlaylist
// 歌单和曲目在同一个快照里读。如果分开读,删除歌单可能恰好发生在两次读取之间,就会读到歌单还在、曲目却已经没了。
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
p, err := q.SharedPlaylist(ctx, shareID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrShareNotFound
}
if err != nil {
return err
}
rows, err := q.PlaylistItems(ctx, p.ID)
if err != nil {
return err
}
shared = port.SharedPlaylist{
Name: p.Name,
Favorites: p.Kind == favoritesKind,
OwnerName: p.OwnerName,
OwnerAvatarHash: orEmpty(p.OwnerAvatarHash),
Items: playlistItems(rows),
}
return nil
})
if err != nil {
return port.SharedPlaylist{}, fmt.Errorf("读取分享的歌单:%w", err)
}
return shared, nil
}
func (s *Store) CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
var record port.PlaylistRecord
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
created, err := createPlaylist(ctx, q, ownerID, name, limit)
if err != nil {
return err
}
copied, err := q.CopySharedPlaylistItems(ctx, query.CopySharedPlaylistItemsParams{ShareID: shareID, ToID: created.ID})
if err != nil {
return err
}
if !copied.Found {
return port.ErrShareNotFound
}
if err := q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: copied.Copied}); err != nil {
return err
}
record = port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: int(copied.Copied), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}
cover, err := q.PlaylistCover(ctx, created.ID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
record.Cover = &port.Cover{URL: orEmpty(cover.CoverURL), Key: track.ObjectKey(orEmpty(cover.CoverKey))}
return nil
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 复制分享的歌单:%w", ownerID, err)
}
return record, nil
}
func splitTracks(tracks []track.Track) (sources, ids []string) {
sources, ids = make([]string, len(tracks)), make([]string, len(tracks))
for i, t := range tracks {
sources[i], ids[i] = string(t.Source), t.ID
}
return sources, ids
}
@@ -1,847 +0,0 @@
package postgres
import (
"errors"
"slices"
"strconv"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
playlistorder "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/track"
)
func registered(t *testing.T, store *Store, username string) member.Member {
t.Helper()
m := createMember(t, store, username, "游客-"+username)
if err := store.Register(t.Context(), m.ID, username, "hash"); err != nil {
t.Fatal(err)
}
return m
}
func favoritesOf(t *testing.T, store *Store, ownerID string) string {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), ownerID)
if err != nil || len(lists) == 0 || !lists[0].Favorites {
t.Fatalf("歌单 = %#v, %v,期望第一个是“我喜欢”", lists, err)
}
return lists[0].ID
}
var (
sunnyItem = port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269 * time.Second}}
clipItem = port.PlaylistItem{Track: track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}, Meta: track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: "https://example.com/a.mp3", Duration: time.Minute}}
)
func TestRegisterCreatesFavorites(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 1 || !lists[0].Favorites || lists[0].Name != "我喜欢" || lists[0].TrackCount != 0 {
t.Fatalf("注册之后的歌单 = %#v, %v,期望只有一个空的“我喜欢”", lists, err)
}
guest := createMember(t, store, "guest", "游客-1a2b")
if lists, err := store.MemberPlaylists(t.Context(), guest.ID); err != nil || len(lists) != 0 {
t.Errorf("游客的歌单 = %#v, %v,期望没有", lists, err)
}
}
func TestPlaylistLifecycle(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
favorites := favoritesOf(t, store, alice.ID)
commute, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
if err != nil {
t.Fatal(err)
}
if err := store.RenamePlaylist(t.Context(), alice.ID, commute.ID, "上班路上"); err != nil {
t.Fatal(err)
}
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 2 || lists[1].ID != commute.ID || lists[1].Name != "上班路上" || lists[1].TrackCount != 0 || lists[1].Cover != nil {
t.Errorf("歌单 = %#v, %v,期望“我喜欢”之后是改名后的 %s", lists, err, commute.ID)
}
if err := store.RenamePlaylist(t.Context(), alice.ID, favorites, "别的名字"); !errors.Is(err, port.ErrFavoritesFixed) {
t.Errorf("“我喜欢”改名 = %v,期望 ErrFavoritesFixed", err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, favorites); !errors.Is(err, port.ErrFavoritesFixed) {
t.Errorf("删除“我喜欢” = %v,期望 ErrFavoritesFixed", err)
}
for _, id := range []string{commute.ID, "not-a-uuid", "00000000-0000-7000-8000-000000000000"} {
if err := store.RenamePlaylist(t.Context(), bob.ID, id, "x"); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("别人或不存在的歌单 %s 改名 = %v,期望 ErrPlaylistNotFound", id, err)
}
if err := store.DeletePlaylist(t.Context(), bob.ID, id); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("删除别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
if _, err := store.PlaylistItems(t.Context(), bob.ID, id); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("读取别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
}
if err := store.DeletePlaylist(t.Context(), alice.ID, commute.ID); err != nil {
t.Fatal(err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
t.Errorf("删除之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
}
}
func TestDeletePlaylistRemovesItsItems(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
commute, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
if err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, commute.ID, sunnyItem, 1000); err != nil {
t.Fatal(err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, commute.ID); err != nil {
t.Fatal(err)
}
var count int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items WHERE playlist_id = $1`, commute.ID).Scan(&count); err != nil || count != 0 {
t.Errorf("删除歌单之后的曲目数 = %d, %v,期望 0", count, err)
}
}
func TestAccountHasOneFavorites(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
_, err := pool.Exec(t.Context(), `INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'favorites', '我喜欢')`, alice.ID)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != uniqueViolation {
t.Errorf("第二个“我喜欢” = %v,期望唯一约束错误", err)
}
}
func TestPlaylistLimitCountsFavorites(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
if _, err := store.CreatePlaylist(t.Context(), alice.ID, "一", 2); err != nil {
t.Fatal(err)
}
if _, err := store.CreatePlaylist(t.Context(), alice.ID, "二", 2); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("超过上限时 = %v,期望 ErrPlaylistLimit", err)
}
}
func TestPlaylistItems(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
favorites := favoritesOf(t, store, alice.ID)
for _, item := range []port.PlaylistItem{sunnyItem, clipItem, sunnyItem} {
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 2); err != nil {
t.Fatal(err)
}
}
items, err := store.PlaylistItems(t.Context(), alice.ID, favorites)
if want := []port.PlaylistItem{clipItem, sunnyItem}; err != nil || !slices.Equal(items, want) {
t.Errorf("曲目 = %#v, %v,期望新加入的在前、重复加入不变 %#v", items, err, want)
}
if lists, _ := store.MemberPlaylists(t.Context(), alice.ID); lists[0].TrackCount != 2 {
t.Errorf("曲目数 = %d,期望 2", lists[0].TrackCount)
}
third := port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香", PageURL: "https://music.163.com/#/song?id=185811"}}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, third, 2); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单满了时 = %v,期望 ErrPlaylistLimit", err)
}
if err := store.AddPlaylistItem(t.Context(), bob.ID, favorites, third, 2); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("加入别人的歌单 = %v,期望 ErrPlaylistNotFound", err)
}
for range 2 {
if err := store.RemovePlaylistItems(t.Context(), alice.ID, favorites, []track.Track{sunnyItem.Track}); err != nil {
t.Fatal(err)
}
}
if items, err := store.PlaylistItems(t.Context(), alice.ID, favorites); err != nil || !slices.Equal(items, []port.PlaylistItem{clipItem}) {
t.Errorf("移除之后的曲目 = %#v, %v", items, err)
}
if err := store.RemovePlaylistItems(t.Context(), bob.ID, favorites, []track.Track{clipItem.Track}); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("从别人的歌单移除 = %v,期望 ErrPlaylistNotFound", err)
}
}
func TestConcurrentPlaylistAddsRespectLimit(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
favorites := favoritesOf(t, store, alice.ID)
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
item := port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: strconv.Itoa(i)}, Meta: track.Meta{Title: "歌", PageURL: "https://music.163.com/"}}
_ = store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 5)
})
}
wg.Wait()
if items, err := store.PlaylistItems(t.Context(), alice.ID, favorites); err != nil || len(items) != 5 {
t.Errorf("并发加入之后有 %d 首, %v,期望正好 5 首", len(items), err)
}
var distinct, seq int64
var largest int64
if err := pool.QueryRow(t.Context(), `SELECT count(DISTINCT i.position), max(i.position), max(p.item_seq)
FROM playlist_items i JOIN playlists p ON p.id = i.playlist_id WHERE i.playlist_id = $1`, favorites).Scan(&distinct, &largest, &seq); err != nil {
t.Fatal(err)
}
if distinct != 5 || largest > seq {
t.Errorf("排序值有 %d 种、最大 %d,歌单的 item_seq 是 %d,期望 5 首的排序值两两不同且都不大于 item_seq", distinct, largest, seq)
}
}
func TestConcurrentPlaylistCreatesRespectLimit(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
_, _ = store.CreatePlaylist(t.Context(), alice.ID, "歌单"+strconv.Itoa(i), 5)
})
}
wg.Wait()
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 5 {
t.Errorf("并发新建之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err)
}
}
func TestPlaylistTimestamps(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
created, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
if err != nil || created.CreatedAt.IsZero() || created.UpdatedAt.IsZero() || created.Cover != nil {
t.Fatalf("新建 = %#v, %v,期望带创建和更新时间、没有封面", created, err)
}
read := func() port.PlaylistRecord {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v", lists, err)
}
return lists[1]
}
before := read()
if !before.CreatedAt.Equal(created.CreatedAt) || !before.UpdatedAt.Equal(created.UpdatedAt) {
t.Errorf("读出的时间 %v/%v,期望与新建时一致 %v/%v", before.CreatedAt, before.UpdatedAt, created.CreatedAt, created.UpdatedAt)
}
steps := []struct {
name string
do func() error
changed bool
}{
{"改名", func() error { return store.RenamePlaylist(t.Context(), alice.ID, created.ID, "上班路上") }, true},
{"加入新曲目", func() error { return store.AddPlaylistItem(t.Context(), alice.ID, created.ID, sunnyItem, 10) }, true},
{"重复加入", func() error { return store.AddPlaylistItem(t.Context(), alice.ID, created.ID, sunnyItem, 10) }, false},
{"移除曲目", func() error {
return store.RemovePlaylistItems(t.Context(), alice.ID, created.ID, []track.Track{sunnyItem.Track})
}, true},
{"移除不存在的曲目", func() error {
return store.RemovePlaylistItems(t.Context(), alice.ID, created.ID, []track.Track{sunnyItem.Track})
}, false},
}
for _, step := range steps {
if err := step.do(); err != nil {
t.Fatalf("%s:%v", step.name, err)
}
after := read()
if got := after.UpdatedAt.After(before.UpdatedAt); got != step.changed {
t.Errorf("%s之后更新时间推进 = %v,期望 %v", step.name, got, step.changed)
}
if !after.CreatedAt.Equal(before.CreatedAt) {
t.Errorf("%s之后创建时间变了:%v -> %v", step.name, before.CreatedAt, after.CreatedAt)
}
before = after
}
}
func TestPlaylistCoverIsTheFirstSongWithOne(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
favorites := favoritesOf(t, store, alice.ID)
add := func(source track.Source, id, coverURL string, coverKey track.ObjectKey) {
t.Helper()
item := port.PlaylistItem{
Track: track.Track{Source: source, ID: id},
Meta: track.Meta{Title: id, CoverURL: coverURL, CoverKey: coverKey, PageURL: "https://example.com/" + id},
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 100); err != nil {
t.Fatal(err)
}
}
for i := range 3 {
add(track.NetEase, strconv.Itoa(i), "https://img.example/"+strconv.Itoa(i)+".jpg", "")
}
add(track.Link, "https://example.com/a.mp3", "", "covers/a.jpg")
add(track.NetEase, "no-cover", "", "")
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 1 {
t.Fatalf("歌单 = %#v, %v", lists, err)
}
if got, want := lists[0].Cover, (port.Cover{Key: "covers/a.jpg"}); got == nil || *got != want {
t.Errorf("封面 = %+v,期望跳过没有封面的歌,取排在最前的 %+v", got, want)
}
}
func songItem(id string) port.PlaylistItem {
return port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: id}, Meta: track.Meta{Title: "歌" + id, Artist: "艺人", CoverURL: "https://example.com/" + id + ".jpg", PageURL: "https://music.163.com/#/song?id=" + id, Duration: time.Minute}}
}
func displayIDs(t *testing.T, store *Store, ownerID, id string) []string {
t.Helper()
items, err := store.PlaylistItems(t.Context(), ownerID, id)
if err != nil {
t.Fatal(err)
}
ids := make([]string, len(items))
for i, item := range items {
ids[i] = item.Track.ID
}
return ids
}
// 依次加入 ids,显示顺序与 ids 相反。
func filled(t *testing.T, store *Store, ownerID, name string, ids ...string) string {
t.Helper()
p, err := store.CreatePlaylist(t.Context(), ownerID, name, 50)
if err != nil {
t.Fatal(err)
}
for _, id := range ids {
if err := store.AddPlaylistItem(t.Context(), ownerID, p.ID, songItem(id), 1000); err != nil {
t.Fatal(err)
}
}
return p.ID
}
func updatedAt(t *testing.T, store *Store, ownerID, id string) time.Time {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), ownerID)
if err != nil {
t.Fatal(err)
}
for _, l := range lists {
if l.ID == id {
return l.UpdatedAt
}
}
t.Fatalf("没有歌单 %s", id)
return time.Time{}
}
func trackList(ids ...string) []track.Track {
out := make([]track.Track, len(ids))
for i, id := range ids {
out[i] = track.Track{Source: track.NetEase, ID: id}
}
return out
}
func TestRemovePlaylistItemsIgnoresMissingTracks(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
list := filled(t, store, alice.ID, "通勤", "1", "2", "3", "4")
if err := store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList("2", "9", "4", "2")); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, alice.ID, list); !slices.Equal(got, []string{"3", "1"}) {
t.Errorf("批量移出之后 = %v,期望 [3 1]", got)
}
}
func TestCopyPlaylistItems(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
from := filled(t, store, alice.ID, "来源", "1", "2", "3", "4")
to := filled(t, store, alice.ID, "目标", "3", "9")
before := updatedAt(t, store, alice.ID, to)
n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("4", "x", "3", "1", "4"), 1000)
if err != nil || n != 2 {
t.Fatalf("复制 = %d, %v,期望跳过已有的、不存在的和重复的,复制 2 首", n, err)
}
if got := displayIDs(t, store, alice.ID, to); !slices.Equal(got, []string{"4", "1", "9", "3"}) {
t.Errorf("复制之后 = %v,期望新复制的在最前且保持给定的先后 [4 1 9 3]", got)
}
items, _ := store.PlaylistItems(t.Context(), alice.ID, to)
if want := songItem("4"); items[0] != want {
t.Errorf("复制的曲目 = %#v,期望带着元数据快照 %#v", items[0], want)
}
if !updatedAt(t, store, alice.ID, to).After(before) {
t.Error("复制之后目标歌单的更新时间没有推进")
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, to, songItem("5"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, alice.ID, to); got[0] != "5" {
t.Errorf("复制之后新加入的歌 = %v,期望仍在最前", got)
}
before = updatedAt(t, store, alice.ID, to)
if n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("1", "x"), 1000); err != nil || n != 0 {
t.Errorf("没有可复制的歌 = %d, %v,期望 0", n, err)
}
if !updatedAt(t, store, alice.ID, to).Equal(before) {
t.Error("没有复制任何歌,更新时间却变了")
}
if n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("2"), 5); !errors.Is(err, port.ErrPlaylistLimit) || n != 0 {
t.Errorf("装不下时 = %d, %v,期望 ErrPlaylistLimit", n, err)
}
if n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("2"), 6); err != nil || n != 1 {
t.Errorf("正好装满时 = %d, %v,期望复制 1 首", n, err)
}
full := filled(t, store, alice.ID, "满", "1", "2")
if _, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, full, trackList("3", "4", "1"), 3); !errors.Is(err, port.ErrPlaylistLimit) {
t.Fatalf("超限 = %v,期望 ErrPlaylistLimit", err)
}
if got := displayIDs(t, store, alice.ID, full); !slices.Equal(got, []string{"2", "1"}) {
t.Errorf("整批拒绝之后 = %v,期望一首也没有复制", got)
}
other := filled(t, store, bob.ID, "别人的", "7")
for name, call := range map[string]func() (int, error){
"来源是别人的": func() (int, error) {
return store.CopyPlaylistItems(t.Context(), alice.ID, other, to, trackList("7"), 1000)
},
"目标是别人的": func() (int, error) {
return store.CopyPlaylistItems(t.Context(), alice.ID, from, other, trackList("1"), 1000)
},
"来源不是 UUID": func() (int, error) {
return store.CopyPlaylistItems(t.Context(), alice.ID, "x", to, trackList("1"), 1000)
},
"目标不是 UUID": func() (int, error) {
return store.CopyPlaylistItems(t.Context(), alice.ID, from, "x", trackList("1"), 1000)
},
} {
if n, err := call(); !errors.Is(err, port.ErrPlaylistNotFound) || n != 0 {
t.Errorf("%s = %d, %v,期望 ErrPlaylistNotFound", name, n, err)
}
}
}
func TestConcurrentCrossCopiesDoNotDeadlock(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
a := filled(t, store, alice.ID, "甲", "1", "2")
b := filled(t, store, alice.ID, "乙", "3", "4")
var wg sync.WaitGroup
for range 20 {
wg.Go(func() { _, _ = store.CopyPlaylistItems(t.Context(), alice.ID, a, b, trackList("1", "2"), 1000) })
wg.Go(func() { _, _ = store.CopyPlaylistItems(t.Context(), alice.ID, b, a, trackList("3", "4"), 1000) })
}
wg.Wait()
for _, id := range []string{a, b} {
if got := displayIDs(t, store, alice.ID, id); len(got) != 4 {
t.Errorf("互相复制之后有 %v,期望各 4 首", got)
}
}
}
func TestConcurrentRemovalsAndMovesDoNotDeadlock(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
ids := make([]string, 40)
for i := range ids {
ids[i] = strconv.Itoa(i)
}
list := filled(t, store, alice.ID, "通勤", ids...)
errs := make(chan error, 40)
var wg sync.WaitGroup
for i := range 20 {
wg.Go(func() { errs <- store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList(ids[i])) })
wg.Go(func() { errs <- store.MovePlaylistItems(t.Context(), alice.ID, list, trackList(ids[20+i]), nil) })
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Errorf("并发移出和移动 = %v,期望都成功", err)
}
}
got := displayIDs(t, store, alice.ID, list)
slices.Sort(got)
want := slices.Clone(ids[20:])
slices.Sort(want)
if !slices.Equal(got, want) {
t.Errorf("并发移出和移动之后有 %v,期望只剩没有移出的 %v", got, want)
}
}
func TestConcurrentRemovalsAndDeletesDoNotDeadlock(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
lists := make([]string, 20)
for i := range lists {
lists[i] = filled(t, store, alice.ID, "歌单"+strconv.Itoa(i), "1", "2", "3")
}
removed, deleted := make(chan error, len(lists)), make(chan error, len(lists))
var wg sync.WaitGroup
for _, list := range lists {
wg.Go(func() { removed <- store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList("1", "2", "3")) })
wg.Go(func() { deleted <- store.DeletePlaylist(t.Context(), alice.ID, list) })
}
wg.Wait()
close(removed)
close(deleted)
for err := range removed {
if err != nil && !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("与删除歌单并发移出 = %v,期望成功或 ErrPlaylistNotFound", err)
}
}
for err := range deleted {
if err != nil {
t.Errorf("与移出并发删除歌单 = %v,期望成功", err)
}
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
t.Errorf("删除之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
}
var count int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items`).Scan(&count); err != nil || count != 0 {
t.Errorf("删除歌单之后还有 %d 首曲目, %v,期望 0", count, err)
}
}
func TestTrackPlaylistsOnlyListsOwnPlaylists(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
favorites := favoritesOf(t, store, alice.ID)
commute := filled(t, store, alice.ID, "通勤", "1")
filled(t, store, alice.ID, "空", "2")
bobFavorites := favoritesOf(t, store, bob.ID)
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, songItem("1"), 10); err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), bob.ID, bobFavorites, songItem("1"), 10); err != nil {
t.Fatal(err)
}
ids, err := store.TrackPlaylists(t.Context(), alice.ID, songItem("1").Track)
if want := []string{favorites, commute}; err != nil || !slices.Equal(ids, want) {
t.Errorf("所在的歌单 = %v, %v,期望只有本人的 %v", ids, err, want)
}
ids, err = store.TrackPlaylists(t.Context(), alice.ID, songItem("3").Track)
if err != nil || ids == nil || len(ids) != 0 {
t.Errorf("不在任何歌单里的歌 = %#v, %v,期望空切片", ids, err)
}
}
func TestMovePlaylistItems(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
list := filled(t, store, alice.ID, "通勤", "5", "4", "3", "2", "1")
at := func(id string) *track.Track { return &trackList(id)[0] }
steps := []struct {
name string
moved []track.Track
before *track.Track
want []string
}{
{"移到最前", trackList("4"), at("1"), []string{"4", "1", "2", "3", "5"}},
{"移到最后", trackList("4"), nil, []string{"1", "2", "3", "5", "4"}},
{"移到中间", trackList("1"), at("5"), []string{"2", "3", "1", "5", "4"}},
{"多首保持给定的先后", trackList("4", "2"), at("3"), []string{"4", "2", "3", "1", "5"}},
{"忽略不在歌单里的歌", trackList("x", "5"), at("2"), []string{"4", "5", "2", "3", "1"}},
}
for _, step := range steps {
if err := store.MovePlaylistItems(t.Context(), alice.ID, list, step.moved, step.before); err != nil {
t.Fatalf("%s:%v", step.name, err)
}
if got := displayIDs(t, store, alice.ID, list); !slices.Equal(got, step.want) {
t.Errorf("%s之后 = %v,期望 %v", step.name, got, step.want)
}
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, list, songItem("6"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, alice.ID, list); got[0] != "6" {
t.Errorf("移动之后新加入的歌 = %v,期望仍在最前", got)
}
errs := []struct {
name string
moved []track.Track
before *track.Track
want error
}{
{"before 是被移动的歌", trackList("1", "2"), at("2"), playlistorder.ErrBeforeMoved},
{"before 不在歌单里", trackList("1"), at("x"), playlistorder.ErrBeforeMissing},
}
for _, e := range errs {
if err := store.MovePlaylistItems(t.Context(), alice.ID, list, e.moved, e.before); !errors.Is(err, e.want) {
t.Errorf("%s = %v,期望 %v", e.name, err, e.want)
}
}
if err := store.MovePlaylistItems(t.Context(), bob.ID, list, trackList("1"), nil); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("移动别人的歌单 = %v,期望 ErrPlaylistNotFound", err)
}
if err := store.MovePlaylistItems(t.Context(), alice.ID, "not-a-uuid", trackList("1"), nil); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("移动不存在的歌单 = %v,期望 ErrPlaylistNotFound", err)
}
}
func TestMovePlaylistItemsAdvancesUpdatedAt(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
list := filled(t, store, alice.ID, "通勤", "3", "2", "1")
before := updatedAt(t, store, alice.ID, list)
if err := store.MovePlaylistItems(t.Context(), alice.ID, list, trackList("x"), nil); err != nil {
t.Fatal(err)
}
if !updatedAt(t, store, alice.ID, list).Equal(before) {
t.Error("没有歌被移动,更新时间却变了")
}
if err := store.MovePlaylistItems(t.Context(), alice.ID, list, trackList("1"), nil); err != nil {
t.Fatal(err)
}
if !updatedAt(t, store, alice.ID, list).After(before) {
t.Error("移动之后更新时间没有推进")
}
}
func TestImportPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
items := []port.PlaylistItem{songItem("3"), clipItem, songItem("1")}
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "导入的歌单", items, 50)
if err != nil {
t.Fatal(err)
}
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || !slices.Equal(got, items) {
t.Errorf("导入之后的曲目 = %#v, %v,期望与来源同序 %#v", got, err, items)
}
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和导入的歌单", lists, err)
}
read := lists[1]
if imported.ID != read.ID || imported.Name != "导入的歌单" || imported.Favorites || imported.TrackCount != 3 ||
!imported.CreatedAt.Equal(read.CreatedAt) || !imported.UpdatedAt.Equal(read.UpdatedAt) {
t.Errorf("导入返回 %#v,期望与读出的 %#v 一致、共 3 首", imported, read)
}
if want := (port.Cover{URL: songItem("3").Meta.CoverURL}); imported.Cover == nil || *imported.Cover != want || read.Cover == nil || *read.Cover != want {
t.Errorf("封面 = %+v / %+v,期望排在最前的 %+v", imported.Cover, read.Cover, want)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, imported.ID, songItem("9"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, alice.ID, imported.ID); !slices.Equal(got, []string{"9", "3", clipItem.Track.ID, "1"}) {
t.Errorf("导入之后再加入 = %v,期望新加入的排在最前", got)
}
}
func TestImportEmptyPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "空的", nil, 50)
if err != nil || imported.Name != "空的" || imported.TrackCount != 0 || imported.Cover != nil {
t.Fatalf("导入空列表 = %#v, %v,期望建出一个没有曲目的歌单", imported, err)
}
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || len(got) != 0 {
t.Errorf("曲目 = %#v, %v,期望为空", got, err)
}
}
func TestImportPlaylistRollsBackAsAWhole(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
items := []port.PlaylistItem{songItem("1"), songItem("2")}
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "超限", items, 1); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
}
broken := songItem("3")
broken.Meta.Title = "坏\x00标题"
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "写入失败", append(items, broken), 50); err == nil {
t.Error("有一首写不进去时应报错")
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
t.Errorf("失败之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
}
var count int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items`).Scan(&count); err != nil || count != 0 {
t.Errorf("失败之后的曲目数 = %d, %v,期望 0", count, err)
}
}
func TestConcurrentImportsRespectLimit(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
_, _ = store.ImportPlaylist(t.Context(), alice.ID, "导入"+strconv.Itoa(i), []port.PlaylistItem{songItem(strconv.Itoa(i))}, 5)
})
}
wg.Wait()
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 5 {
t.Errorf("并发导入之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err)
}
}
func TestSharePlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
hash := "0bc83cb571cd1c50ba6f3e8a78ef1346"
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, &hash, nil); err != nil {
t.Fatal(err)
}
favorites := favoritesOf(t, store, alice.ID)
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, sunnyItem, 1000); err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, clipItem, 1000); err != nil {
t.Fatal(err)
}
before := updatedAt(t, store, alice.ID, favorites)
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-1"); err != nil {
t.Fatal(err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "share-1" {
t.Errorf("歌单 = %#v, %v,期望带上分享 ID", lists, err)
}
if !updatedAt(t, store, alice.ID, favorites).Equal(before) {
t.Error("分享改变了歌单的更新时间")
}
shared, err := store.SharedPlaylist(t.Context(), "share-1")
want := port.SharedPlaylist{Name: "我喜欢", Favorites: true, OwnerName: alice.Name, OwnerAvatarHash: hash, Items: []port.PlaylistItem{clipItem, sunnyItem}}
if err != nil || shared.Name != want.Name || shared.Favorites != want.Favorites || shared.OwnerName != want.OwnerName ||
shared.OwnerAvatarHash != want.OwnerAvatarHash || !slices.Equal(shared.Items, want.Items) {
t.Errorf("分享的歌单 = %#v, %v,期望 %#v", shared, err, want)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-2"); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("重新生成之后的旧链接 = %v,期望 ErrShareNotFound", err)
}
if shared, err := store.SharedPlaylist(t.Context(), "share-2"); err != nil || len(shared.Items) != 2 {
t.Errorf("新链接 = %#v, %v", shared, err)
}
for _, id := range []string{favorites, "not-a-uuid", "00000000-0000-7000-8000-000000000000"} {
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, "share-3"); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, ""); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("停止分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
}
for range 2 {
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, ""); err != nil {
t.Fatalf("停止分享 = %v,期望重复停止也成功", err)
}
}
if _, err := store.SharedPlaylist(t.Context(), "share-2"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "" {
t.Errorf("停止分享之后的歌单 = %#v, %v,期望没有分享 ID", lists, err)
}
}
func TestShareIDIsUnique(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
if err := store.SetPlaylistShare(t.Context(), alice.ID, favoritesOf(t, store, alice.ID), "share-1"); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, favoritesOf(t, store, bob.ID), "share-1"); err == nil {
t.Error("同一个分享 ID 设给第二个歌单时应报错")
}
if shared, err := store.SharedPlaylist(t.Context(), "share-1"); err != nil || shared.OwnerName != alice.Name {
t.Errorf("分享 = %#v, %v,期望仍是第一个歌单", shared, err)
}
}
func TestDeletedPlaylistIsNoLongerShared(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
list := filled(t, store, alice.ID, "通勤", "1")
if err := store.SetPlaylistShare(t.Context(), alice.ID, list, "share-1"); err != nil {
t.Fatal(err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, list); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("删除歌单之后 = %v,期望 ErrShareNotFound", err)
}
}
func TestCopySharedPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
source := filled(t, store, alice.ID, "通勤", "1", "2", "3")
if err := store.MovePlaylistItems(t.Context(), alice.ID, source, trackList("1"), &trackList("3")[0]); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, source, "share-1"); err != nil {
t.Fatal(err)
}
original := displayIDs(t, store, alice.ID, source)
copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "抄来的", 50)
if err != nil {
t.Fatal(err)
}
if want := (port.Cover{URL: songItem("1").Meta.CoverURL}); copied.Name != "抄来的" || copied.Favorites || copied.TrackCount != 3 ||
copied.ShareID != "" || copied.Cover == nil || *copied.Cover != want {
t.Errorf("复制返回 %#v,期望 3 首、封面是排在最前的 %+v、没有分享", copied, want)
}
lists, err := store.MemberPlaylists(t.Context(), bob.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和复制的歌单", lists, err)
}
if read := lists[1]; read.ID != copied.ID || read.Name != copied.Name || read.TrackCount != 3 || read.ShareID != "" ||
!read.CreatedAt.Equal(copied.CreatedAt) || !read.UpdatedAt.Equal(copied.UpdatedAt) {
t.Errorf("读出的 %#v,期望与复制返回的 %#v 一致", read, copied)
}
items, err := store.PlaylistItems(t.Context(), bob.ID, copied.ID)
sourceItems, _ := store.PlaylistItems(t.Context(), alice.ID, source)
if err != nil || !slices.Equal(items, sourceItems) {
t.Errorf("复制的曲目 = %#v, %v,期望与原歌单相同 %#v", items, err, sourceItems)
}
if err := store.AddPlaylistItem(t.Context(), bob.ID, copied.ID, songItem("9"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, bob.ID, copied.ID); !slices.Equal(got, append([]string{"9"}, original...)) {
t.Errorf("复制之后再加入 = %v,期望新加入的排在最前、其余保持 %v", got, original)
}
if got := displayIDs(t, store, alice.ID, source); !slices.Equal(got, original) {
t.Errorf("原歌单 = %v,期望不变 %v", got, original)
}
empty := filled(t, store, alice.ID, "空的")
if err := store.SetPlaylistShare(t.Context(), alice.ID, empty, "share-2"); err != nil {
t.Fatal(err)
}
if copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-2", "空的", 50); err != nil || copied.TrackCount != 0 || copied.Cover != nil {
t.Errorf("复制空歌单 = %#v, %v,期望建出一个没有曲目的歌单", copied, err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "超限", 3); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-x", "失效", 50); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("读不到的分享 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), bob.ID); err != nil || len(lists) != 3 {
t.Errorf("失败之后有 %d 个歌单, %v,期望没有多建", len(lists), err)
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"github.com/dihemoe/alisten/db/migrations"
)
// 只读取版本表,不用 goose 的 HasPending,因为 HasPending 在缺少版本表时会建表,而就绪检查不能修改数据库。
// 只读取版本表:goose 的 HasPending 在缺少版本表时会建表,就绪检查不能修改数据库。
func CheckMigrations(ctx context.Context, pool *pgxpool.Pool) error {
db := stdlib.OpenDBFromPool(pool)
defer func() { _ = db.Close() }()
+1 -1
View File
@@ -25,7 +25,7 @@ func addTicket(t *testing.T, pool *pgxpool.Pool, roomID string) {
t.Helper()
_, err := pool.Exec(t.Context(), `
WITH m AS (
INSERT INTO members (name) VALUES ('甲')
INSERT INTO members (token_hash, name) VALUES (sha256(gen_random_uuid()::text::bytea), '甲')
RETURNING id
)
INSERT INTO room_tickets (token_hash, room_id, member_id) SELECT sha256(gen_random_uuid()::text::bytea), $1, id FROM m`,
+6 -13
View File
@@ -12,27 +12,20 @@ import (
var _ port.Site = (*Store)(nil)
func (s *Store) Settings(ctx context.Context) (port.SiteSettings, error) {
row, err := s.q.SiteSettings(ctx)
func (s *Store) BrandName(ctx context.Context) (string, error) {
name, err := s.q.BrandName(ctx)
if errors.Is(err, pgx.ErrNoRows) {
return port.SiteSettings{RegistrationOpen: true}, nil
return "", nil
}
if err != nil {
return port.SiteSettings{}, fmt.Errorf("读取站点设置:%w", err)
return "", fmt.Errorf("读取品牌名:%w", err)
}
return port.SiteSettings{BrandName: orEmpty(row.BrandName), RegistrationOpen: row.RegistrationOpen}, nil
return name, nil
}
func (s *Store) SetBrandName(ctx context.Context, name string) error {
if err := s.q.SetBrandName(ctx, &name); err != nil {
if err := s.q.SetBrandName(ctx, name); err != nil {
return fmt.Errorf("保存品牌名:%w", err)
}
return nil
}
func (s *Store) SetRegistrationOpen(ctx context.Context, open bool) error {
if err := s.q.SetRegistrationOpen(ctx, open); err != nil {
return fmt.Errorf("保存注册开关:%w", err)
}
return nil
}
+9 -23
View File
@@ -1,34 +1,20 @@
package postgres
import (
"testing"
import "testing"
"github.com/dihemoe/alisten/internal/app/port"
)
func TestSiteSettings(t *testing.T) {
func TestBrandName(t *testing.T) {
store, pool := newStore(t)
check := func(want port.SiteSettings, why string) {
t.Helper()
if got, err := store.Settings(t.Context()); err != nil || got != want {
t.Errorf("%s:Settings = %#v, %v,期望 %#v", why, got, err, want)
}
if name, err := store.BrandName(t.Context()); err != nil || name != "" {
t.Fatalf("没有设置过时 = %q, %v,期望空字符串", name, err)
}
check(port.SiteSettings{RegistrationOpen: true}, "没有设置过时没有品牌名、开放注册")
if err := store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
check(port.SiteSettings{RegistrationOpen: false}, "只关闭注册时品牌名仍未设置")
for _, name := range []string{"周末听歌会", "Alisten"} {
if err := store.SetBrandName(t.Context(), name); err != nil {
for _, want := range []string{"周末听歌会", "Alisten"} {
if err := store.SetBrandName(t.Context(), want); err != nil {
t.Fatal(err)
}
check(port.SiteSettings{BrandName: name, RegistrationOpen: false}, "修改品牌名不影响注册开关")
if got, err := store.BrandName(t.Context()); err != nil || got != want {
t.Errorf("BrandName = %q, %v,期望 %q", got, err, want)
}
}
if err := store.SetRegistrationOpen(t.Context(), true); err != nil {
t.Fatal(err)
}
check(port.SiteSettings{BrandName: "Alisten", RegistrationOpen: true}, "重新开放注册不影响品牌名")
var rows int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM site").Scan(&rows); err != nil {
t.Fatal(err)
+2 -2
View File
@@ -149,8 +149,8 @@ func entryOf(r query.RoomEntriesRow) room.Entry {
Round: int(r.Round),
AddedAt: r.AddedAt.UTC(),
}
// 曲目表只保存准备成功的结果,有对应的行就是已就绪。元数据也取自曲目表,因为在房间没有 owner 时准备好的歌,
// 条目里还是点歌时的元数据,比如链接曲目的标题还是链接本身。
// 曲目表只保存准备成功的结果,有对应的行就是已就绪。元数据也取自曲目表:在房间没有 owner 时准备好的歌,
// 条目中仍是点歌时的元数据,比如链接曲目的标题仍是链接本身。
if r.DurationMs != nil {
e.Ready = true
e.Meta, e.Prep = prepared(*r.TrackTitle, orEmpty(r.TrackArtist), orEmpty(r.TrackAlbum), orEmpty(r.TrackCoverURL), orEmpty(r.TrackCoverKey), *r.TrackPageURL, *r.DurationMs, *r.Gain, orEmpty(r.Lyric), orEmpty(r.LyricWords), orEmpty(r.LyricTrans))
@@ -80,7 +80,7 @@ func TestSavedSnapshotIsRestored(t *testing.T) {
addTrack(t, store, netease("1"), meta, playing)
addTrack(t, store, link, linkMeta, queued)
// 准备中的链接曲目也可能带有封面的对象键,比如音频已不在对象存储里、要重新准备的歌。
// 准备中的链接曲目也可能带有封面的对象键:例如音频已不在对象存储中、要重新准备的歌。
unprepared := track.Track{Source: track.Link, ID: "https://example.com/c.mp3"}
saved := room.Snapshot{
Info: room.Info{ID: id, Name: "新名字", Description: "新描述", Kind: room.Persistent},
+1 -1
View File
@@ -19,7 +19,7 @@ func New(pool *pgxpool.Pool) *Store {
var uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
// 房间 ID 可能来自客户端。格式不正确的 uuid 参数会让 PostgreSQL 报错,而这样的房间一定不存在。
// 房间 ID 可能来自客户端:格式不正确的 uuid 参数会让 PostgreSQL 报错,而这样的房间一定不存在。
func isUUID(s string) bool {
return uuidPattern.MatchString(s)
}
+6 -29
View File
@@ -6,17 +6,13 @@ import (
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
var (
_ port.Tickets = (*Store)(nil)
_ port.ExistingTickets = (*Store)(nil)
)
var _ port.Tickets = (*Store)(nil)
func (s *Store) CreateTicket(ctx context.Context, t port.NewTicket) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
@@ -31,10 +27,10 @@ func (s *Store) CreateTicket(ctx context.Context, t port.NewTicket) error {
if hash != t.PasswordHash {
return port.ErrPasswordChanged
}
return q.CreateTicket(ctx, query.CreateTicketParams{TokenHash: t.TokenHash, RoomID: t.RoomID, MemberID: t.MemberID, SessionTokenHash: t.SessionTokenHash})
return q.CreateTicket(ctx, query.CreateTicketParams{TokenHash: t.TokenHash, RoomID: t.RoomID, MemberID: t.MemberID})
})
if err != nil {
return fmt.Errorf("签发房间 %s 的票据:%w", t.RoomID, sessionError(err))
return fmt.Errorf("签发房间 %s 的票据:%w", t.RoomID, err)
}
return nil
}
@@ -48,27 +44,8 @@ func (s *Store) TicketHolder(ctx context.Context, tokenHash []byte) (port.Ticket
return port.TicketHolder{}, fmt.Errorf("查找票据:%w", err)
}
return port.TicketHolder{
RoomID: row.RoomID,
Member: member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)},
Bot: row.Bot,
SessionTokenHash: row.SessionTokenHash,
RoomID: row.RoomID,
Member: member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)},
Bot: row.Bot,
}, nil
}
func (s *Store) ExistingTickets(ctx context.Context, tokenHashes [][]byte) ([][]byte, error) {
existing, err := s.q.ExistingTickets(ctx, tokenHashes)
if err != nil {
return nil, fmt.Errorf("核对 %d 张票据:%w", len(tokenHashes), err)
}
return existing, nil
}
const foreignKeyViolation = "23503"
func sessionError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == foreignKeyViolation && pgErr.ConstraintName == "room_tickets_session_token_hash_fkey" {
return port.ErrSessionNotFound
}
return err
}
+3 -110
View File
@@ -2,8 +2,6 @@ package postgres
import (
"errors"
"reflect"
"slices"
"testing"
"time"
@@ -18,15 +16,15 @@ func TestTicketHolder(t *testing.T) {
alice := createMember(t, store, "alice", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID, SessionTokenHash: tokenHash("alice")}); err != nil {
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID}); err != nil {
t.Fatal(err)
}
got, err := store.TicketHolder(t.Context(), tokenHash("t1"))
if want := (port.TicketHolder{RoomID: id, Member: alice, SessionTokenHash: tokenHash("alice")}); err != nil || !reflect.DeepEqual(got, want) {
if want := (port.TicketHolder{RoomID: id, Member: alice}); err != nil || got != want {
t.Errorf("TicketHolder = %#v, %v,期望 %#v", got, err, want)
}
renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil, nil)
renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil)
if err != nil {
t.Fatal(err)
}
@@ -39,111 +37,6 @@ func TestTicketHolder(t *testing.T) {
}
}
func TestExistingTickets(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
for _, ticket := range []string{"t1", "t2"} {
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash(ticket), RoomID: id, MemberID: alice.ID, SessionTokenHash: tokenHash("alice")}); err != nil {
t.Fatal(err)
}
}
resetAPIKey(t, store, id, "key")
got, err := store.ExistingTickets(t.Context(), [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key"), tokenHash("t3"), tokenHash("t1")})
if want := [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key")}; err != nil || !sameHashes(got, want) {
t.Errorf("ExistingTickets = %x, %v,期望 t1、t2 和 API Key 各一次", got, err)
}
if err := store.DeleteSession(t.Context(), tokenHash("alice")); err != nil {
t.Fatal(err)
}
got, err = store.ExistingTickets(t.Context(), [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key")})
if want := [][]byte{tokenHash("key")}; err != nil || !sameHashes(got, want) {
t.Errorf("会话删除之后 ExistingTickets = %x, %v,期望只剩 API Key", got, err)
}
}
func TestCreateTicketRejectsDeletedSession(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
if err := store.DeleteSession(t.Context(), tokenHash("alice")); err != nil {
t.Fatal(err)
}
err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID, SessionTokenHash: tokenHash("alice")})
if !errors.Is(err, port.ErrSessionNotFound) {
t.Errorf("会话已经删除时 CreateTicket = %v,期望 ErrSessionNotFound", err)
}
}
func TestTicketsBelongToSessions(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
for _, guest := range []string{"guest-2", "guest-3", "guest-4"} {
createMember(t, store, guest, "游客")
}
for _, s := range []struct{ guest, token string }{{"guest-2", "alice-2"}, {"guest-3", "alice-3"}} {
if err := store.ReplaceSession(t.Context(), tokenHash(s.guest), alice.ID, tokenHash(s.token), ""); err != nil {
t.Fatal(err)
}
}
for _, session := range []string{"alice-1", "alice-2", "alice-3", "guest-4"} {
memberID := alice.ID
if session == "guest-4" {
memberID = ownerOf(t, store, "guest-4")
}
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("ticket-" + session), RoomID: id, MemberID: memberID, SessionTokenHash: tokenHash(session)}); err != nil {
t.Fatal(err)
}
}
valid := func(session string) bool {
t.Helper()
_, err := store.TicketHolder(t.Context(), tokenHash("ticket-"+session))
if err != nil && !errors.Is(err, port.ErrTicketNotFound) {
t.Fatal(err)
}
return err == nil
}
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
}
deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1"))
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
}
if !valid("alice-1") || valid("alice-2") || valid("alice-3") {
t.Error("删除会话之后,只有保留的会话的票据仍然有效")
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest-4"), alice.ID, tokenHash("alice-4"), "new"); err != nil {
t.Fatal(err)
}
if valid("guest-4") {
t.Error("登录换掉游客的会话之后,游客的票据仍然有效")
}
if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil {
t.Fatal(err)
}
if valid("alice-1") {
t.Error("退出登录之后,这个会话的票据仍然有效")
}
}
// 不计顺序。
func sameHashes(got, want [][]byte) bool {
key := func(hashes [][]byte) []string {
s := make([]string, len(hashes))
for i, h := range hashes {
s[i] = string(h)
}
slices.Sort(s)
return s
}
return slices.Equal(key(got), key(want))
}
func TestCreateTicketChecksPasswordHash(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
+4 -23
View File
@@ -13,10 +13,7 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
_ port.Tracks = (*Store)(nil)
_ port.TrackLookup = (*Store)(nil)
)
var _ port.Tracks = (*Store)(nil)
func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.PreparedTrack, bool, error) {
row, err := s.q.PreparedTrack(ctx, query.PreparedTrackParams{Source: string(t.Source), TrackID: t.ID})
@@ -30,19 +27,6 @@ func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.Prepared
return port.PreparedTrack{Meta: meta, Result: result, MediaKey: orEmpty(row.MediaKey), MediaSize: orZero(row.MediaSize)}, true, nil
}
func (s *Store) TrackMetas(ctx context.Context, ts []track.Track) (map[track.Track]track.Meta, error) {
sources, ids := splitTracks(ts)
rows, err := s.q.TrackMetas(ctx, query.TrackMetasParams{Sources: sources, TrackIds: ids})
if err != nil {
return nil, fmt.Errorf("读取 %d 首歌的元数据:%w", len(ts), err)
}
found := make(map[track.Track]track.Meta, len(rows))
for _, r := range rows {
found[track.Track{Source: track.Source(r.Source), ID: r.TrackID}] = storedMeta(r.Title, orEmpty(r.Artist), orEmpty(r.Album), orEmpty(r.CoverURL), orEmpty(r.CoverKey), r.PageURL, r.DurationMs)
}
return found, nil
}
func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.PreparedTrack) error {
params := query.SavePreparedTrackParams{
Source: string(t.Source),
@@ -69,12 +53,9 @@ func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.Pre
}
func prepared(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64, gain float64, lyric, lyricWords, lyricTrans string) (track.Meta, track.Prepared) {
meta := storedMeta(title, artist, album, coverURL, coverKey, pageURL, durationMs)
return meta, track.Prepared{Duration: meta.Duration, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
}
func storedMeta(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64) track.Meta {
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: time.Duration(durationMs) * time.Millisecond}
d := time.Duration(durationMs) * time.Millisecond
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: d},
track.Prepared{Duration: d, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
}
func orZero(p *int64) int64 {
@@ -1,7 +1,6 @@
package postgres
import (
"maps"
"testing"
"time"
@@ -125,42 +124,3 @@ func TestLinkTrackRoundTrip(t *testing.T) {
t.Errorf("恢复出的条目 = %+v,期望已就绪,元数据取自曲目表", snap.Waiting[0])
}
}
func TestTrackMetas(t *testing.T) {
store, _ := newStore(t)
link := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
saved := map[track.Track]port.PreparedTrack{
netease("1"): {
Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=1", Duration: time.Minute},
Result: track.Prepared{Duration: 61500 * time.Millisecond, Gain: 0.5, Lyric: "词", LyricWords: "逐字", LyricTrans: "翻译"},
},
link: {
Meta: track.Meta{Title: "a.mp3", CoverKey: "link/abc/cover.webp", PageURL: link.ID, Duration: 3 * time.Second},
Result: track.Prepared{Duration: 3 * time.Second, Gain: 1},
MediaKey: "abc",
MediaSize: 12290,
},
}
want := map[track.Track]track.Meta{}
for tr, p := range saved {
if err := store.SavePreparedTrack(t.Context(), tr, p); err != nil {
t.Fatal(err)
}
read, _, err := store.PreparedTrack(t.Context(), tr)
if err != nil {
t.Fatal(err)
}
want[tr] = read.Meta
}
if want[netease("1")].Duration != 61500*time.Millisecond {
t.Fatalf("PreparedTrack 读出的时长 = %v,期望准备得到的时长", want[netease("1")].Duration)
}
wanted := []track.Track{netease("2"), netease("1"), {Source: track.QQ, ID: "1"}, link, netease("1")}
got, err := store.TrackMetas(t.Context(), wanted)
if err != nil || !maps.Equal(got, want) {
t.Errorf("批量读取 = %#v, %v\n期望只有命中的、与 PreparedTrack 读出的元数据一致 %#v:音源和 ID 都要对上,重复的只算一次", got, err, want)
}
if got, err := store.TrackMetas(t.Context(), nil); err != nil || len(got) != 0 {
t.Errorf("什么也不查 = %#v, %v,期望空的结果", got, err)
}
}
+1 -52
View File
@@ -2,34 +2,11 @@ package qq
import (
"context"
"encoding/json"
"errors"
"net/url"
"strings"
"github.com/dihemoe/alisten/internal/app/port"
)
type accountResponse struct {
Code *int `json:"code"`
Req struct {
Code *int `json:"code"`
Data struct {
Identity *struct {
VIP int `json:"vip"`
} `json:"identity"`
SVIP int `json:"svip"`
} `json:"data"`
} `json:"req"`
}
func (r accountResponse) check() error {
if r.Code == nil || r.Req.Code == nil {
return errors.New("QQ 音乐账号响应的 code 或 req.code 缺失或为 null")
}
return nil
}
func (c *Client) CurrentAccount(ctx context.Context) (*port.SourceAccount, error) {
cookie, err := c.cookie.get(ctx)
if err != nil {
@@ -39,35 +16,7 @@ func (c *Client) CurrentAccount(ctx context.Context) (*port.SourceAccount, error
if id == "" {
return nil, nil
}
payload, err := json.Marshal(map[string]any{
"comm": map[string]any{
"ct": 24, "cv": 4747474, "platform": "yqq.json", "uin": id,
"g_tk": 5381, "g_tk_new_20200303": 5381,
"authst": qqAccountCookieValue(cookie, "qm_keyst"),
"format": "json", "inCharset": "utf8", "outCharset": "utf-8", "notice": 0, "needNewCode": 1,
},
"req": map[string]any{"module": "VipLogin.VipLoginInter", "method": "vip_login_base", "param": map[string]any{}},
})
if err != nil {
return nil, err
}
var resp accountResponse
query := url.Values{"data": {string(payload)}}
if err := c.get(ctx, "qq", "account", musicuAPI, query, desktopHeader, &resp); err != nil {
return nil, err
}
// 非零业务码是 QQ 音乐认为登录无效,与网易云的个人资料为空同样处理:没有账号,不算查询失败。
if *resp.Code != 0 || *resp.Req.Code != 0 {
return nil, nil
}
account := &port.SourceAccount{ID: id, Verified: true}
// identity 缺失时会员状态拿不准,留 nil 让界面显示未知。
if resp.Req.Data.Identity != nil {
account.VIP = new(bool)
*account.VIP = resp.Req.Data.Identity.VIP != 0 || resp.Req.Data.SVIP != 0
}
// QQ 音乐的网页接口对自有账号隐藏昵称,拿不到就不显示。
return account, nil
return &port.SourceAccount{ID: id}, nil
}
func qqAccountID(cookie string) string {
+6 -78
View File
@@ -4,39 +4,15 @@ import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
"github.com/dihemoe/alisten/internal/app/port"
)
func accountTestClient(t *testing.T, cookie string, handler http.HandlerFunc) *Client {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cgi-bin/musicu.fcg" {
t.Errorf("意外的请求 %s", r.URL.Path)
http.NotFound(w, r)
return
}
if got := r.Header.Get("Cookie"); got != cookie {
t.Errorf("账号接口的 Cookie = %q,期望已保存的凭据", got)
}
handler(w, r)
}))
t.Cleanup(server.Close)
target, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
return New(
&http.Client{Transport: redirect{target}},
func(context.Context) (string, error) { return cookie, nil },
nil,
(&observed{}).record,
testLogger,
)
func accountTestClient(cookie string, loadErr error) *Client {
load := func(context.Context) (string, error) { return cookie, loadErr }
return New(&http.Client{}, load, nil, (&observed{}).record, testLogger)
}
func TestCurrentAccount(t *testing.T) {
@@ -61,7 +37,7 @@ func TestCurrentAccount(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := accountTestClient(t, tc.cookie, fixture(t, "account.json")).CurrentAccount(t.Context())
got, err := accountTestClient(tc.cookie, nil).CurrentAccount(t.Context())
if err != nil {
t.Fatal(err)
}
@@ -71,7 +47,7 @@ func TestCurrentAccount(t *testing.T) {
}
return
}
want := &port.SourceAccount{ID: tc.wantID, VIP: new(bool), Verified: true}
want := &port.SourceAccount{ID: tc.wantID}
if !reflect.DeepEqual(got, want) {
t.Fatalf("账号资料 = %+v,期望 %+v", got, want)
}
@@ -79,57 +55,9 @@ func TestCurrentAccount(t *testing.T) {
}
}
// 绿钻和豪华绿钻任一有效都算会员,identity 缺失时 VIP 留 nil 显示未知。
func TestCurrentAccountVIP(t *testing.T) {
for _, tc := range []struct {
file string
wantVIP *bool
}{
{"account-vip.json", boolPtr(true)},
{"account-svip.json", boolPtr(true)},
{"account.json", boolPtr(false)},
{"account-no-identity.json", nil},
} {
got, err := accountTestClient(t, "uin=o000123", fixture(t, tc.file)).CurrentAccount(t.Context())
if err != nil {
t.Fatal(err)
}
if got == nil || got.Verified != true || !reflect.DeepEqual(got.VIP, tc.wantVIP) {
t.Errorf("%s 的 VIP = %v,期望 %v", tc.file, got.VIP, tc.wantVIP)
}
}
}
// QQ 音乐认为登录无效时返回 nil 账号而不是错误,与网易云个人资料为空一致。
func TestCurrentAccountExpired(t *testing.T) {
got, err := accountTestClient(t, "uin=o000123", fixture(t, "account-expired.json")).CurrentAccount(t.Context())
if err != nil || got != nil {
t.Fatalf("登录失效时 = %+v, %v,期望 nil 账号且无错误", got, err)
}
}
// 上游异常是查询失败,不能冒充没有账号。
func TestCurrentAccountUpstreamError(t *testing.T) {
for name, handler := range map[string]http.HandlerFunc{
"HTTP 502": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) },
"响应 JSON 畸形": body(`{"code":0,`),
"业务码缺失": body(`{"code":0,"req":{"data":{}}}`),
} {
t.Run(name, func(t *testing.T) {
if _, err := accountTestClient(t, "uin=o000123", handler).CurrentAccount(t.Context()); err == nil {
t.Error("上游异常时没有返回错误")
}
})
}
}
func TestCurrentAccountCookieError(t *testing.T) {
want := errors.New("读取失败")
load := func(context.Context) (string, error) { return "", want }
c := New(&http.Client{}, load, nil, (&observed{}).record, testLogger)
if _, err := c.CurrentAccount(t.Context()); !errors.Is(err, want) {
if _, err := accountTestClient("", want).CurrentAccount(t.Context()); !errors.Is(err, want) {
t.Fatalf("读取凭据的错误 = %v,期望 %v", err, want)
}
}
func boolPtr(b bool) *bool { return &b }
-60
View File
@@ -1,60 +0,0 @@
package qq
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/dihemoe/alisten/internal/app/port"
)
const playlistDetailAPI = "https://i.y.qq.com/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg"
type playlistDetailResponse struct {
Code int `json:"code"`
Cdlist []struct {
Songlist []struct {
SongMID string `json:"songmid"`
SongName string `json:"songname"`
AlbumName string `json:"albumname"`
AlbumMID string `json:"albummid"`
Interval int64 `json:"interval"`
Singer []singer `json:"singer"`
} `json:"songlist"`
} `json:"cdlist"`
}
func (r playlistDetailResponse) check() error {
if r.Code != 0 {
return fmt.Errorf("QQ 音乐返回错误码 %d", r.Code)
}
return nil
}
func (c *Client) PlaylistTracks(ctx context.Context, playlistID string) ([]port.Song, error) {
return cached(c, "playlist_tracks "+playlistID, func() ([]port.Song, error) {
var resp playlistDetailResponse
query := url.Values{
"type": {"1"}, "json": {"1"}, "utf8": {"1"}, "onlysong": {"0"}, "disstid": {playlistID},
"format": {"json"}, "g_tk": {"5381"}, "loginUin": {"0"}, "hostUin": {"0"},
"inCharset": {"utf8"}, "outCharset": {"utf-8"}, "notice": {"0"}, "platform": {"yqq"}, "needNewCode": {"0"},
}
if err := c.get(ctx, "qq", "playlist_tracks", playlistDetailAPI, query, desktopHeader, &resp); err != nil {
return nil, err
}
if len(resp.Cdlist) == 0 {
return nil, errors.New("QQ 音乐的歌单不存在或不可见")
}
// 这个接口一次返回全部曲目,实测 1137 首的歌单也完整,不需要翻页。
songs := make([]port.Song, 0, len(resp.Cdlist[0].Songlist))
for _, s := range resp.Cdlist[0].Songlist {
// 没有 songmid 的条目是下架等不可用的歌,丢弃但保持其余歌曲的原顺序。
if s.SongMID == "" {
continue
}
songs = append(songs, song(s.SongMID, s.SongName, s.Singer, s.AlbumName, s.AlbumMID, s.Interval))
}
return songs, nil
})
}
-69
View File
@@ -1,69 +0,0 @@
package qq
import (
"net/http"
"reflect"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
func TestPlaylistTracks(t *testing.T) {
c, obs := serve(t, map[string]http.HandlerFunc{
"/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg": func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("disstid"); got != "7382629476" {
t.Errorf("歌单 ID = %q,期望与搜索返回的 dissid 一致", got)
}
fixture(t, "playlist-tracks.json")(w, r)
},
})
want := []port.Song{
{
Track: track.Track{Source: track.QQ, ID: "mid1"},
Meta: track.Meta{
Title: "第一首", Artist: "歌手甲, 歌手乙", Album: "专辑一",
CoverURL: "https://y.gtimg.cn/music/photo_new/T002R300x300M000album1.jpg",
PageURL: "https://y.qq.com/n/ryqq/songDetail/mid1",
Duration: 200 * time.Second,
},
},
{
Track: track.Track{Source: track.QQ, ID: "mid2"},
Meta: track.Meta{
Title: "第二首", Artist: "歌手丙", Album: "专辑二",
CoverURL: "https://y.gtimg.cn/music/photo_new/T002R300x300M000album2.jpg",
PageURL: "https://y.qq.com/n/ryqq/songDetail/mid2",
Duration: 100 * time.Second,
},
},
}
// 缓存命中时第二次调用不再发请求。
for range 2 {
got, err := c.PlaylistTracks(t.Context(), "7382629476")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("歌单内的歌曲 = %#v\n期望 %#v", got, want)
}
}
obs.check(t, "qq playlist_tracks")
}
func TestPlaylistTracksUnavailable(t *testing.T) {
for name, file := range map[string]string{
"接口返回错误码": "playlist-tracks-missing.json",
"cdlist 为空": "playlist-tracks-empty.json",
} {
t.Run(name, func(t *testing.T) {
c, _ := serve(t, map[string]http.HandlerFunc{
"/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg": fixture(t, file),
})
if _, err := c.PlaylistTracks(t.Context(), "999999999999"); err == nil {
t.Error("不存在或不可见的歌单没有返回错误")
}
})
}
}
-1
View File
@@ -1 +0,0 @@
{"code":0,"req":{"code":301,"data":{}}}
@@ -1 +0,0 @@
{"code":0,"req":{"code":0,"data":{}}}
Loaded 100 of 483 files, more files were not shown because too many files have changed in this diff. Show more