Compare commits
56
Commits
v0.3.4
..
6f286c2eb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f286c2eb2 | ||
|
|
19a135c983 | ||
|
|
52c4aaa9d3 | ||
|
|
b986abf937 | ||
|
|
d72b4203cb | ||
|
|
0f6ecaf6da | ||
|
|
8cacfb68ff | ||
|
|
c7a5c44b1c | ||
|
|
fb751ca57f | ||
|
|
c39331aa75 | ||
|
|
3bc1d6e82e | ||
|
|
2ae286dcb8 | ||
|
|
c1d5bb2de0 | ||
|
|
1157843f50 | ||
|
|
e95476185b | ||
|
|
7543fd4987 | ||
|
|
6098ac392e | ||
|
|
ad0d0900d8 | ||
|
|
240c8899a9 | ||
|
|
7863c02170 | ||
|
|
1f99bd939b | ||
|
|
8b941764b1 | ||
|
|
eed25578c7 | ||
|
|
9f23e97652 | ||
|
|
910e7339ad | ||
|
|
17e486209e | ||
|
|
112527db83 | ||
|
|
ce5ce141f8 | ||
|
|
a12ab44ec8 | ||
|
|
038cbb206b | ||
|
|
df8e0c5b8b | ||
|
|
649916ab9d | ||
|
|
9256f64e24 | ||
|
|
83f8dbf3a5 | ||
|
|
92a8e63240 | ||
|
|
1b292fa5e3 | ||
|
|
a72fc40519 | ||
|
|
0f01b1dfe9 | ||
|
|
5198fa5126 | ||
|
|
96a3da903c | ||
|
|
2657dca9e1 | ||
|
|
fafde9041c | ||
|
|
95a56267a4 | ||
|
|
ffbf28c165 | ||
|
|
f0f38ff511 | ||
|
|
a80c48824f | ||
|
|
a0956299be | ||
|
|
246baf5a0e | ||
|
|
62d99221fe | ||
|
|
fd1b3addb3 | ||
|
|
d8f9d4e2ca | ||
|
|
98a1605a83 | ||
|
|
773ec302a7 | ||
|
|
d17477ba59 | ||
|
|
97764f9769 | ||
|
|
47908cedcf |
No files matched your search
@@ -1,9 +1,10 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
workflow_call:
|
||||
secrets:
|
||||
K3S_KUBECONFIG:
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: cd-alisten
|
||||
@@ -15,13 +16,9 @@ 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.event.workflow_run.head_branch }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
steps:
|
||||
- uses: azure/setup-helm@v5
|
||||
with:
|
||||
@@ -59,7 +56,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
KUBECONFIG: ${{ runner.temp }}/k3s-kubeconfig
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
@@ -162,3 +162,13 @@ 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
@@ -7,7 +7,7 @@ linters:
|
||||
- forbidigo
|
||||
exclusions:
|
||||
rules:
|
||||
# 这两个文件中未检查的错误不影响结果:decode.go 跳过 fmt 块的对齐填充失败时,读取下一个块头会报错;loudness_bench_test.go 的 binary.Write 写入 bytes.Buffer,不会失败。
|
||||
# 这两个文件里未检查的错误不影响结果。decode.go 跳过 fmt 块的对齐填充失败时,读下一个块头会报错。loudness_bench_test.go 的 binary.Write 写入的是 bytes.Buffer,不会失败。
|
||||
- path: internal/lib/wav/decode\.go|internal/lib/loudness/loudness_bench_test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
@@ -17,8 +17,8 @@ linters:
|
||||
- forbidigo
|
||||
settings:
|
||||
depguard:
|
||||
# 不以 $ 结尾的条目按前缀匹配。depguard 只拿排序后紧挨在导入路径之前的一项比较,已有前缀的子包不要再单列:
|
||||
# deny 中同时有 os 和 os/exec 时,os/signal 就不再被禁止。
|
||||
# 不以 $ 结尾的条目按前缀匹配。depguard 只拿排序后紧挨在导入路径之前的那一项比较,所以已经有前缀的子包不要再单列。
|
||||
# 比如 deny 里同时有 os 和 os/exec 时,os/signal 就不再被禁止。
|
||||
rules:
|
||||
domain:
|
||||
list-mode: strict
|
||||
@@ -77,9 +77,9 @@ linters:
|
||||
- github.com/dihemoe/alisten/internal/domain
|
||||
- github.com/dihemoe/alisten/internal/lib
|
||||
- github.com/dihemoe/alisten/internal/app
|
||||
# 用于房间密码的 argon2id 哈希;纯计算,不做 I/O。
|
||||
# 用于房间密码和账号密码的 argon2id 哈希。argon2 是纯计算,不做 I/O。
|
||||
- golang.org/x/crypto/argon2
|
||||
# 用于合并同一首歌的并发播放地址解析;只协调 goroutine,不做 I/O。
|
||||
# 用于合并同一首歌的并发播放地址解析。singleflight 只协调 goroutine,不做 I/O。
|
||||
- golang.org/x/sync/singleflight
|
||||
deny:
|
||||
- pkg: os
|
||||
@@ -138,7 +138,7 @@ linters:
|
||||
desc: lib 与业务无关,不能依赖 bootstrap
|
||||
- pkg: github.com/dihemoe/alisten/protocol
|
||||
desc: protocol 只能被 internal/adapter/in 使用
|
||||
# app 通过 logctx 记录日志的关联字段;logctx 只依赖标准库,app 才不会经由它依赖 OpenTelemetry。
|
||||
# app 通过 logctx 记录日志的关联字段。logctx 只依赖标准库,这样 app 才不会经由 logctx 依赖 OpenTelemetry。
|
||||
logctx:
|
||||
list-mode: strict
|
||||
files:
|
||||
@@ -157,7 +157,7 @@ linters:
|
||||
list-mode: lax
|
||||
files:
|
||||
- "**/internal/adapter/out/**"
|
||||
# sqlc 把查询代码生成在 postgres 适配器的 internal 目录下;lax 模式让这个更长的 allow 前缀优先于 deny。
|
||||
# sqlc 把查询代码生成在 postgres 适配器的 internal 目录下。用 lax 模式,是为了让这个更长的 allow 前缀优先于 deny。
|
||||
allow:
|
||||
- github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query
|
||||
deny:
|
||||
|
||||
@@ -78,6 +78,25 @@ 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` | 发送房间聊天消息 |
|
||||
|
||||
## 使用与版权
|
||||
|
||||
本项目不提供音源内容及其使用授权。音源名称、音频、封面和歌词等内容的权利归各自权利人所有。
|
||||
|
||||
@@ -102,6 +102,8 @@ 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":
|
||||
@@ -149,6 +151,9 @@ 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))
|
||||
}
|
||||
|
||||
+43
-10
@@ -107,6 +107,8 @@ 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"`
|
||||
@@ -148,7 +150,7 @@ func parseIDL(data []byte) (*idl, error) {
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// encoding/json 对重复的键只保留最后一个值,并把显式的 null 解码为 nil,而之后的检查和生成都假定节点存在,所以解码之前先拒绝这两种写法。
|
||||
// encoding/json 对重复的键只保留最后一个值,还会把显式的 null 解码为 nil。之后的检查和生成都假定节点存在,所以解码之前先拒绝重复的键和显式的 null。
|
||||
// encoding/json 匹配结构体字段时不区分大小写,会把 "MAXLENGTH" 当作 maxLength,所以关键字只接受规范的写法。
|
||||
func checkJSON(dec *json.Decoder, where string, named bool) error {
|
||||
tok, err := dec.Token()
|
||||
@@ -191,7 +193,7 @@ func checkJSON(dec *json.Decoder, where string, named bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 值为对象、键由 IDL 作者命名的字段(类型名、属性名、状态码)。新增这类字段时要加入这个列表,否则与关键字只有大小写不同的名字会被拒绝。
|
||||
// 列表中字段的值都是对象,对象的键由 IDL 作者命名(类型名、属性名、错误码)。新增这类字段时要加入这个列表,否则与关键字只有大小写不同的名字会被拒绝。
|
||||
var nameKeyed = []string{"$defs", "properties", "x-httpStatus"}
|
||||
|
||||
var idlKeywords = jsonNames(idl{}, wsSection{}, message{}, event{}, endpoint{}, httpBinding{}, closeCode{}, schema{})
|
||||
@@ -253,7 +255,7 @@ func pathParams(path string) []string {
|
||||
return params
|
||||
}
|
||||
|
||||
// 生成的 TypeScript client 中,路径参数与这些名字是同一个函数中的标识符,不能重名。
|
||||
// 在生成的 TypeScript client 中,路径参数和列表中的名字是同一个函数里的标识符,所以不能重名。
|
||||
var tsClientNames = []string{"body", "send", "encodeURIComponent"}
|
||||
|
||||
var tsFixedNames = []string{
|
||||
@@ -439,21 +441,47 @@ 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 inbound || !required {
|
||||
return fmt.Errorf("%s:数组只能出现在响应和事件中,并且必须是必填字段", where)
|
||||
if !required {
|
||||
return fmt.Errorf("%s:数组必须是必填字段", where)
|
||||
}
|
||||
if err := allowOnly(where, s, "type", "description", "items"); err != nil {
|
||||
allowed := []string{"type", "description", "items"}
|
||||
if inbound {
|
||||
allowed = append(allowed, "minItems", "maxItems")
|
||||
}
|
||||
if err := allowOnly(where, s, allowed...); 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 {
|
||||
@@ -533,7 +561,7 @@ func (d *idl) resolve(where, ref string) (*schema, error) {
|
||||
return d.Defs[name], nil
|
||||
}
|
||||
|
||||
// 分支的 properties 中不在 required 里的字段是各分支共用的可选字段:每个分支都要列出同样的一组,解码时不参与匹配分支。
|
||||
// 分支的 properties 里不在 required 中的字段,是各分支共用的可选字段。每个分支都要列出同样的一组可选字段,解码时它们不参与匹配分支。
|
||||
func checkAlternatives(where string, s *schema) error {
|
||||
if len(s.OneOf) < 2 {
|
||||
return fmt.Errorf("%s:oneOf 至少要有两个分支", where)
|
||||
@@ -695,7 +723,7 @@ type endpointNames struct {
|
||||
}
|
||||
|
||||
func (n endpointNames) addRoute(where string, b httpBinding, owner string) error {
|
||||
// 路径参数的名字不影响路由:ServeMux 登记两条这样的路由会 panic,OpenAPI 也把它们视为同一个 path。
|
||||
// 路径参数的名字不影响路由。两条路由如果只有参数名不同,ServeMux 登记时会 panic,OpenAPI 也把它们视为同一个 path。
|
||||
shape := pathParamPattern.ReplaceAllString(b.Path, "{}")
|
||||
route := b.Method + " " + shape
|
||||
if first, ok := n.routes[route]; ok {
|
||||
@@ -744,7 +772,7 @@ func (d *idl) checkRoomRoute(where string, m *message, names endpointNames) erro
|
||||
}
|
||||
if b.Method == "GET" || b.Method == "DELETE" {
|
||||
for _, name := range slices.Sorted(maps.Keys(m.Request.Properties)) {
|
||||
if d.jsonType(m.Request.Properties[name]) == "object" {
|
||||
if t := d.jsonType(m.Request.Properties[name]); t != "string" && t != "integer" {
|
||||
return fmt.Errorf("%s:%s 请求的字段 %s 来自路径或查询串,应是字符串、整数或枚举", where, b.Method, name)
|
||||
}
|
||||
}
|
||||
@@ -752,7 +780,7 @@ func (d *idl) checkRoomRoute(where string, m *message, names endpointNames) erro
|
||||
return names.addRoute(where, b, "房间内请求 "+m.Type+" 的路由")
|
||||
}
|
||||
|
||||
// 请求的字段已经检查过,不会是数组、布尔或 null,所以只有 string、integer 和 object 三种。
|
||||
// 请求的字段已经检查过,不会是 null、布尔或数字,所以只有 string、integer、object 和 array 四种。
|
||||
func (d *idl) jsonType(s *schema) string {
|
||||
if s.Ref != "" {
|
||||
return d.Defs[refName(s.Ref)].Type
|
||||
@@ -962,6 +990,9 @@ 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
|
||||
}
|
||||
@@ -1055,6 +1086,8 @@ 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")
|
||||
|
||||
@@ -143,17 +143,33 @@ 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": map[string]any{"type": "string"}},
|
||||
}, "ids")
|
||||
"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:数组只能出现在响应和事件中"},
|
||||
}, "$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")
|
||||
d["x-events"] = []any{map[string]any{"type": "batch", "data": ref("Batch")}}
|
||||
}, "不支持 minItems"},
|
||||
{"生成的类型名重名", func(d map[string]any) {
|
||||
defs(d)["RoomSyncRequest"] = object(map[string]any{})
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
|
||||
@@ -230,6 +246,11 @@ func TestParseIDLRejects(t *testing.T) {
|
||||
defs(d)["Track"] = object(map[string]any{"source": ref("Kind"), "id": str()}, "source", "id")
|
||||
d["x-requests"] = []any{roomBinding("GET", "/api/room", map[string]any{"track": ref("Track")}, "track")}
|
||||
}, "x-requests[0].http:GET 请求的字段 track 来自路径或查询串,应是字符串、整数或枚举"},
|
||||
{"DELETE 请求有数组字段", func(d map[string]any) {
|
||||
withKind(d)
|
||||
kinds := map[string]any{"type": "array", "items": ref("Kind"), "maxItems": 10}
|
||||
d["x-requests"] = []any{roomBinding("DELETE", "/api/room/queue", map[string]any{"kinds": kinds}, "kinds")}
|
||||
}, "x-requests[0].http:DELETE 请求的字段 kinds 来自路径或查询串,应是字符串、整数或枚举"},
|
||||
{"204 的房间内请求有响应", func(d map[string]any) {
|
||||
m := roomBinding("POST", "/api/room/messages", map[string]any{})
|
||||
m["http"].(map[string]any)["status"] = 204
|
||||
|
||||
@@ -75,8 +75,9 @@ func generateFrom(path string) ([]output, error) {
|
||||
return generate(d)
|
||||
}
|
||||
|
||||
// 生成函数只处理 check 接受过的 IDL,直接依赖它保证的结构,不再检查:引用都指向存在的类型(标量类型已经展开),数组都有 items,
|
||||
// anyOf 是 [类型, {"type": "null"}],allOf 的每个条件只按必填的枚举字段判断,需要解码函数的类型都已标记在 inbound 中。
|
||||
// 生成函数只处理 check 接受过的 IDL,所以直接依赖 check 保证的结构,不再重复检查。check 保证了以下几点:
|
||||
// 引用都指向存在的类型(标量类型已经展开),数组都有 items,anyOf 是 [类型, {"type": "null"}],
|
||||
// allOf 的每个条件只按必填的枚举字段判断,需要解码函数的类型都已标记在 inbound 中。
|
||||
func generate(d *idl) ([]output, error) {
|
||||
types, err := goTypes(d)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,6 +48,10 @@ 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, "")
|
||||
}
|
||||
@@ -257,6 +261,24 @@ 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)
|
||||
|
||||
@@ -212,6 +212,21 @@
|
||||
"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",
|
||||
|
||||
+49
-1
@@ -183,6 +183,23 @@
|
||||
},
|
||||
"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": "房间",
|
||||
@@ -318,7 +335,7 @@
|
||||
},
|
||||
"info": {
|
||||
"title": "Sample",
|
||||
"version": "362d144af81ba8bf"
|
||||
"version": "5e495c1e23fa03a5"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -774,6 +791,37 @@
|
||||
],
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -4,7 +4,7 @@ package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const Version = "362d144af81ba8bf"
|
||||
const Version = "5e495c1e23fa03a5"
|
||||
|
||||
type AdminUpdateRoomRequest struct {
|
||||
Homepage *string `json:"homepage,omitempty"`
|
||||
@@ -88,6 +88,18 @@ 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"`
|
||||
@@ -154,6 +166,7 @@ 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 } from '../realtime/protocol.gen'
|
||||
import type { ErrorInfo, Member, Room, Track } from '../realtime/protocol.gen'
|
||||
|
||||
export interface AdminUpdateRoomRequest {
|
||||
homepage?: string
|
||||
@@ -25,6 +25,10 @@ export interface CreateRoomResponse {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface RemoveTracksRequest {
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: ErrorInfo
|
||||
}
|
||||
@@ -43,6 +47,7 @@ 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 = '362d144af81ba8bf'
|
||||
export const PROTOCOL_VERSION = '5e495c1e23fa03a5'
|
||||
|
||||
export type Empty = Record<string, never>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- +goose Up
|
||||
-- 可选的文本值在没有或不适用时为 NULL;空串只表示值本身为空,例如被清空的房间描述。
|
||||
-- 可选的文本值没有或不适用时为 NULL。空串只表示值本身为空,例如被清空的房间描述。
|
||||
CREATE TABLE members (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
@@ -15,12 +15,12 @@ CREATE TABLE rooms (
|
||||
name text NOT NULL,
|
||||
description text NOT NULL,
|
||||
password_hash text,
|
||||
-- 接管房间时加 1;落盘以它为条件,因此过期的 owner 无法写入。
|
||||
-- 每次接管房间时加 1。落盘以 term 为条件,所以过期的 owner 无法写入。
|
||||
term bigint NOT NULL DEFAULT 0,
|
||||
play_mode text NOT NULL DEFAULT 'sequential' CHECK (play_mode IN ('sequential', 'random', 'rotation')),
|
||||
entry_seq bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- 第一次生成 API Key 时创建,此后重置 Key、修改密码都不换。
|
||||
-- bot 成员在第一次生成 API Key 时创建,之后重置 Key、修改密码都不会更换。
|
||||
bot_member_id uuid REFERENCES members,
|
||||
CHECK (bot_member_id IS NULL OR kind = 'persistent')
|
||||
);
|
||||
@@ -46,7 +46,7 @@ CREATE TABLE room_entries (
|
||||
added_at timestamptz NOT NULL,
|
||||
started_at timestamptz CHECK ((slot = 'current') = (started_at IS NOT NULL)),
|
||||
page_url text NOT NULL,
|
||||
-- 条目的轮次在入队时定下,轮流播放时点赞数相同的歌按它排列。
|
||||
-- 条目的轮次在入队时定下。轮流播放时,点赞数相同的歌按轮次排列。
|
||||
round bigint NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (room_id, entry_id)
|
||||
);
|
||||
@@ -105,7 +105,7 @@ CREATE TABLE credentials (
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 至多一行;没有这一行时品牌名是默认的 Alisten。
|
||||
-- site 表至多一行。没有这一行时,品牌名是默认的 Alisten。
|
||||
CREATE TABLE site (
|
||||
id boolean PRIMARY KEY DEFAULT true CHECK (id),
|
||||
brand_name text NOT NULL
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- +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;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- +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);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- +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);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- +goose Up
|
||||
-- 分享链接中的随机串,NULL 表示没有分享。明文保存,这样所有者随时可以再复制一次链接。
|
||||
ALTER TABLE playlists ADD COLUMN share_id text UNIQUE;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +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);
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- 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;
|
||||
+13
-4
@@ -1,10 +1,19 @@
|
||||
-- name: ListStoredMedia :many
|
||||
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
|
||||
WHERE media_key IS NOT NULL
|
||||
ORDER BY prepared_at DESC, track_id;
|
||||
-- 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
|
||||
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
|
||||
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\'
|
||||
ORDER BY CASE WHEN @sort::text = 'stored' THEN prepared_at END DESC,
|
||||
CASE WHEN @sort::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
|
||||
CASE WHEN @sort::text = 'size' THEN media_size END DESC,
|
||||
track_id COLLATE "C"
|
||||
LIMIT @page_size::bigint OFFSET @skip::bigint;
|
||||
|
||||
-- name: CountStoredMedia :one
|
||||
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
|
||||
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\';
|
||||
|
||||
-- name: StoredMedia :one
|
||||
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
|
||||
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
|
||||
WHERE media_key = @media_key::text;
|
||||
|
||||
-- name: DeleteStoredMedia :exec
|
||||
|
||||
+66
-4
@@ -1,14 +1,76 @@
|
||||
-- name: CreateMember :one
|
||||
INSERT INTO members (token_hash, name) VALUES (@token_hash, @name)
|
||||
INSERT INTO members (name) VALUES (@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 token_hash = @token_hash;
|
||||
SELECT * FROM members WHERE id = (SELECT member_id FROM member_sessions 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
|
||||
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
|
||||
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;
|
||||
@@ -0,0 +1,140 @@
|
||||
-- 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;
|
||||
+6
-2
@@ -1,6 +1,10 @@
|
||||
-- name: BrandName :one
|
||||
SELECT brand_name FROM site;
|
||||
-- name: SiteSettings :one
|
||||
SELECT brand_name, registration_open 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;
|
||||
@@ -1,13 +1,18 @@
|
||||
-- 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
|
||||
INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES (@token_hash, @room_id, @member_id);
|
||||
-- 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));
|
||||
|
||||
-- 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
|
||||
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
|
||||
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[]);
|
||||
@@ -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,3 +12,9 @@ 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;
|
||||
@@ -0,0 +1,110 @@
|
||||
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
|
||||
}
|
||||
+115
-25
@@ -19,34 +19,47 @@ 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
|
||||
requestDeadline = 3 * time.Minute
|
||||
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
|
||||
)
|
||||
|
||||
type Services struct {
|
||||
Identity *identity.Service
|
||||
Lobby *lobby.Service
|
||||
Admin *admin.Service
|
||||
Gateway *realtime.Gateway
|
||||
Catalog *catalog.Service
|
||||
Media *media.Library
|
||||
Playback *media.Playback
|
||||
Site *site.Service
|
||||
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
|
||||
|
||||
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
|
||||
StorageURL string
|
||||
// 网页的 origin,结尾没有 /。
|
||||
PublicURL string
|
||||
}
|
||||
|
||||
type server struct {
|
||||
@@ -63,8 +76,12 @@ 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)
|
||||
@@ -96,6 +113,23 @@ 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)
|
||||
@@ -122,21 +156,43 @@ 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.RouteAdminDeleteMedia, srv.adminDeleteMedia)
|
||||
srv.handle(mux, protocol.RouteAdminRemoveMedia, srv.adminRemoveMedia)
|
||||
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
|
||||
srv.handle(mux, protocol.RouteAdminOpenRegistration, srv.adminOpenRegistration)
|
||||
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
|
||||
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) {
|
||||
@@ -148,10 +204,14 @@ 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, func(ctx context.Context, c call) (any, error) {
|
||||
ctx, resp, err := s.serve(ctx, w, r, route.Auth, maxBody, func(ctx context.Context, c call) (any, error) {
|
||||
resp, err := fn(ctx, c)
|
||||
if err != nil || !route.Location {
|
||||
return resp, err
|
||||
@@ -172,15 +232,15 @@ func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc
|
||||
})
|
||||
}
|
||||
|
||||
// 先鉴权再读请求体:后台没有启用时,无论请求是否合法都返回 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) {
|
||||
// 先鉴权再读请求体,这样后台没有启用时,不管请求是否合法都返回 FORBIDDEN。返回的 ctx 带着凭证所属的成员和房间,
|
||||
// 因为出错时的日志也要带上它们。
|
||||
func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, maxBody int64, fn handlerFunc) (logCtx context.Context, _ any, err error) {
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
logCtx, err = ctx, s.panicked(ctx, r, value)
|
||||
}
|
||||
}()
|
||||
c := call{r: r}
|
||||
c := call{r: r, clientIP: clientIP(r)}
|
||||
switch auth {
|
||||
case protocol.AuthNone:
|
||||
case protocol.AuthMember:
|
||||
@@ -196,7 +256,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.bot = holder.RoomID, holder.Member, holder.Bot
|
||||
c.roomID, c.member, c.memberSession, c.bot = holder.RoomID, holder.Member, holder.SessionTokenHash, 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 {
|
||||
@@ -207,7 +267,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, maxBodyBytes))
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
|
||||
if err != nil {
|
||||
return ctx, nil, bodyError(r, err)
|
||||
}
|
||||
@@ -237,8 +297,8 @@ var errUnreadableBody = errors.New("无法读取请求体")
|
||||
// 恢复 panic 的地方已经记录了日志,回复时不再重复记录。
|
||||
var errPanicked = errors.New("服务器出错了,请稍后再试。")
|
||||
|
||||
// 无法读取请求体是客户端的问题,不是内部错误。连接读取失败时,net/http 在返回错误之前已经取消了
|
||||
// r.Context(),此时按客户端断开处理;连接仍在时,是请求体本身不合法,比如分块编码有误。
|
||||
// 无法读取请求体是客户端的问题,不是内部错误。如果是连接读取失败,net/http 在返回错误之前已经取消了
|
||||
// r.Context(),这时按客户端断开处理。如果连接还在,就是请求体本身不合法,比如分块编码有误。
|
||||
func bodyError(r *http.Request, err error) error {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
@@ -266,9 +326,7 @@ var knownErrors = []struct {
|
||||
{errUnreadableBody, protocol.ErrorCodeInvalidArgument},
|
||||
{port.ErrRoomNotFound, protocol.ErrorCodeNotFound},
|
||||
{port.ErrSlugTaken, protocol.ErrorCodeConflict},
|
||||
{media.ErrNotFound, protocol.ErrorCodeNotFound},
|
||||
{media.ErrNotInRoom, protocol.ErrorCodeNotFound},
|
||||
{media.ErrInUse, protocol.ErrorCodeConflict},
|
||||
{identity.ErrInvalidMemberToken, protocol.ErrorCodeUnauthorized},
|
||||
{identity.ErrWrongPassword, protocol.ErrorCodeUnauthorized},
|
||||
{admin.ErrInvalidToken, protocol.ErrorCodeUnauthorized},
|
||||
@@ -276,6 +334,29 @@ 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},
|
||||
@@ -285,6 +366,15 @@ 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) {
|
||||
@@ -294,7 +384,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: "请求体不能超过 64 KiB"}, true
|
||||
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: fmt.Sprintf("请求体不能超过 %d KiB", tooLarge.Limit>>10)}, true
|
||||
}
|
||||
var roomErr *room.Error
|
||||
if errors.As(err, &roomErr) {
|
||||
|
||||
@@ -22,6 +22,7 @@ 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"
|
||||
@@ -34,19 +35,23 @@ 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, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
|
||||
_, gateway := realtime.New(store, rooms, store, store, 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +158,58 @@ 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, ""))
|
||||
@@ -298,7 +355,7 @@ func TestAbortHandlerIsLeftToNetHTTP(t *testing.T) {
|
||||
|
||||
type brokenProfiles struct{ *porttest.Store }
|
||||
|
||||
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string) (member.Member, error) {
|
||||
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string, *string) (member.Member, error) {
|
||||
return member.Member{}, errors.New("连接中断")
|
||||
}
|
||||
|
||||
|
||||
@@ -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: protocol.Track{Source: protocol.Source(s.Track.Source), ID: s.Track.ID}, Meta: meta(s.Meta)}
|
||||
items[i] = protocol.TrackSummary{Track: trackDTO(s.Track), Meta: meta(s.Meta)}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ var roomErrorCodes = map[room.ErrorKind]protocol.ErrorCode{
|
||||
room.NothingPlaying: protocol.ErrorCodeNothingPlaying,
|
||||
}
|
||||
|
||||
// bot 表示 by 是常驻房间的 bot 成员。返回的成员是房间命令的发起人,bot 代填了点歌人时换成那个名字。
|
||||
// bot 表示 by 是常驻房间的 bot 成员。返回的成员是房间命令的发起人。如果 bot 代填了点歌人,返回的成员 ID 不变,名字换成点歌人的名字加上“(bot)”。
|
||||
func (s *server) action(ctx context.Context, roomID string, by member.Member, bot bool, data any) (room.Action, member.Member, error) {
|
||||
switch d := data.(type) {
|
||||
case protocol.RoomSyncRequest:
|
||||
@@ -48,11 +48,7 @@ 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 {
|
||||
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{Track: trackOf(*d.Track), Requester: deref(d.Requester)}
|
||||
}
|
||||
return realtime.Pick{Source: track.Source(*d.Source), Keyword: *d.Keyword, Requester: deref(d.Requester)}
|
||||
}
|
||||
@@ -145,18 +141,42 @@ 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: protocol.Track{Source: protocol.Source(e.Track.Source), ID: e.Track.ID},
|
||||
Meta: meta(e.Meta),
|
||||
Track: trackDTO(e.Track),
|
||||
Meta: s.trackMeta(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
|
||||
@@ -218,7 +238,7 @@ func (s *server) activity(a room.Activity) protocol.RoomActivityEvent {
|
||||
return d
|
||||
}
|
||||
|
||||
// 歌名里已经有书名号时不再加一层:B 站的标题常带《》。
|
||||
// 歌名里已经有书名号时不再加一层,因为 B 站的标题常带《》。
|
||||
func bookTitle(title string) string {
|
||||
if strings.ContainsAny(title, "《》") {
|
||||
return title
|
||||
|
||||
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -10,23 +9,24 @@ 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 看到它就该停下,而不是重连之后互相顶掉。
|
||||
// 不用 1012,因为共用一把 Key 的两个 bot 收到 4409 就该停下,而不是重连之后互相顶掉。
|
||||
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, s.observe)
|
||||
ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, maxBodyBytes, s.observe)
|
||||
cancel()
|
||||
if err != nil {
|
||||
s.fail(ctx, w, r, err)
|
||||
@@ -81,9 +81,7 @@ func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 确认票据失败或 panic 时都要退订,否则事件流的名额和房间频道一直被占着。
|
||||
func (s *server) observe(ctx context.Context, c call) (any, error) {
|
||||
// 内存中只留凭证的哈希。
|
||||
credential := sha256.Sum256([]byte(c.ticket))
|
||||
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.bot, string(credential[:]))
|
||||
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.memberSession, c.bot, string(identity.TicketCredential(c.ticket)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -93,7 +91,7 @@ func (s *server) observe(ctx context.Context, c call) (any, error) {
|
||||
s.Gateway.Unobserve(o)
|
||||
}
|
||||
}()
|
||||
// 作废时先删除票据、再通知断开:事件流在本实例生效之前发出的通知它收不到,所以生效之后再确认一次票据。
|
||||
// 作废票据时先删除票据,再通知断开。如果通知发出时事件流还没在本实例生效,事件流就收不到,所以生效之后再确认一次票据。
|
||||
if _, err := s.Identity.VerifyTicket(ctx, c.ticket); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -118,7 +116,7 @@ type eventStream struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// data 只占一行:json.Marshal 的输出中没有换行。
|
||||
// data 只占一行,因为 json.Marshal 的输出里没有换行。
|
||||
func (st eventStream) event(ctx context.Context, name, id string, data any) bool {
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
|
||||
@@ -145,11 +145,45 @@ 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()
|
||||
@@ -264,7 +298,7 @@ func TestRoomEventsOpeningTimesOut(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
store := porttest.New()
|
||||
f := newWSFixtureWithTickets(t, store, stallingTickets{store}, store, store)
|
||||
// 期限之后客户端也放弃:服务端没有期限时,这里不会一直等下去。
|
||||
// 客户端到期限之后也放弃,这样服务端没有设期限时,测试不会一直等下去。
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 2*helloTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://alisten.test/api/room/events", nil)
|
||||
@@ -288,7 +322,7 @@ func TestRoomEventsOpeningTimesOut(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// 第二次查找票据,即事件流生效之后的确认,发生 panic。
|
||||
// 第二次查找票据时发生 panic,也就是事件流生效之后的那次确认。
|
||||
type panickingRecheck struct {
|
||||
*porttest.Store
|
||||
lookups atomic.Int32
|
||||
@@ -335,7 +369,7 @@ func TestRoomEventsEndWhenClientStopsReading(t *testing.T) {
|
||||
}
|
||||
_, m, ticket := f.enter(t, hall)
|
||||
f.stream(t, ticket)
|
||||
// 内存连接上的写入要等对方读取:客户端不读,这个事件就一直写不出去。
|
||||
// 内存连接上的写入要等对方读取,所以客户端不读,这个事件就一直写不出去。
|
||||
if _, err := f.gateway.Do(t.Context(), hall, m, room.Chat{Text: "你好"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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)
|
||||
}
|
||||
@@ -13,11 +13,12 @@ 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}$`)
|
||||
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}$`)
|
||||
)
|
||||
|
||||
func (s *server) createMember(ctx context.Context, c call) (any, error) {
|
||||
@@ -31,8 +32,20 @@ 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(_ context.Context, c call) (any, error) {
|
||||
return protocol.GetMeResponse{Member: memberDTO(c.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) updateMe(ctx context.Context, c call) (any, error) {
|
||||
@@ -88,7 +101,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, id, deref(req.Password))
|
||||
ticket, err := s.Identity.IssueTicket(ctx, c.member.ID, bearer(c.r), id, deref(req.Password))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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)
|
||||
}
|
||||
@@ -3,10 +3,9 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/media"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
@@ -43,33 +42,68 @@ func (s *server) redirectTarget(ctx context.Context, r *http.Request, t track.Tr
|
||||
return s.Playback.URL(ctx, t, time.Now())
|
||||
}
|
||||
|
||||
var mediaKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
var mediaSorts = map[protocol.MediaSort]port.MediaSort{
|
||||
protocol.MediaSortStored: port.MediaByStored,
|
||||
protocol.MediaSortIdle: port.MediaByIdle,
|
||||
protocol.MediaSortSize: port.MediaBySize,
|
||||
}
|
||||
|
||||
func (s *server) adminListMedia(ctx context.Context, _ call) (any, error) {
|
||||
list, err := s.Media.List(ctx)
|
||||
func (s *server) adminListMedia(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeAdminListMediaRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]protocol.StoredMedia, len(list))
|
||||
for i, m := range list {
|
||||
q := port.MediaQuery{Page: 1, Sort: port.MediaByStored}
|
||||
if req.Page != nil {
|
||||
q.Page = int(*req.Page)
|
||||
}
|
||||
if req.Sort != nil {
|
||||
q.Sort = mediaSorts[*req.Sort]
|
||||
}
|
||||
if req.Q != nil {
|
||||
q.Title = *req.Q
|
||||
}
|
||||
page, err := s.Media.List(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]protocol.StoredMedia, len(page.Items))
|
||||
for i, m := range page.Items {
|
||||
link := m.Link
|
||||
// 后台把 link 当作链接打开,B 站的歌给出视频页面。
|
||||
// 后台把 link 当作链接打开,所以 B 站的歌给出视频页面。
|
||||
if m.Source == track.Bilibili {
|
||||
link = track.BilibiliPageURL(m.Link)
|
||||
}
|
||||
items[i] = protocol.StoredMedia{Key: m.Key, Source: protocol.Source(m.Source), Link: link, Title: m.Title, Size: m.Size, StoredAtMs: m.StoredAt.UnixMilli()}
|
||||
items[i] = protocol.StoredMedia{
|
||||
Key: m.Key,
|
||||
Source: protocol.Source(m.Source),
|
||||
Link: link,
|
||||
Title: m.Title,
|
||||
Artist: m.Artist,
|
||||
CoverURL: s.coverURL(m.CoverURL, m.CoverKey),
|
||||
Size: m.Size,
|
||||
StoredAtMs: m.StoredAt.UnixMilli(),
|
||||
}
|
||||
if m.LastPlayedAt != nil {
|
||||
played := m.LastPlayedAt.UnixMilli()
|
||||
items[i].LastPlayedAtMs = &played
|
||||
}
|
||||
}
|
||||
return protocol.AdminListMediaResponse{Items: items}, nil
|
||||
return protocol.AdminListMediaResponse{Items: items, Total: int64(page.Total), TotalBytes: page.TotalBytes, Page: int64(q.Page), PageSize: port.MediaPageSize}, nil
|
||||
}
|
||||
|
||||
func (s *server) adminDeleteMedia(ctx context.Context, c call) (any, error) {
|
||||
key := c.r.PathValue("key")
|
||||
if !mediaKeyPattern.MatchString(key) {
|
||||
return nil, media.ErrNotFound
|
||||
func (s *server) adminRemoveMedia(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeAdminRemoveMediaRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, s.Media.Delete(ctx, key)
|
||||
keys := make([]string, len(req.Media))
|
||||
for i, m := range req.Media {
|
||||
keys[i] = m.Key
|
||||
}
|
||||
deleted, inUse, err := s.Media.DeleteMany(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.AdminRemoveMediaResponse{Deleted: deleted, InUse: inUse}, nil
|
||||
}
|
||||
@@ -24,63 +24,63 @@ import (
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
type storedMedia struct {
|
||||
items []port.StoredMedia
|
||||
used bool
|
||||
}
|
||||
|
||||
func (m *storedMedia) ListStoredMedia(context.Context) ([]port.StoredMedia, error) {
|
||||
return m.items, nil
|
||||
}
|
||||
|
||||
func (m *storedMedia) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
|
||||
for _, item := range m.items {
|
||||
if item.Key == key {
|
||||
return item, true, nil
|
||||
}
|
||||
}
|
||||
return port.StoredMedia{}, false, nil
|
||||
}
|
||||
|
||||
func (m *storedMedia) DeleteStoredMedia(context.Context, string) error {
|
||||
m.items = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *storedMedia) TrackInUse(context.Context, track.Track) (bool, error) {
|
||||
return m.used, nil
|
||||
}
|
||||
|
||||
func TestAdminMedia(t *testing.T) {
|
||||
key := media.Key("https://example.com/a.mp3")
|
||||
other := media.Key("https://example.com/b.mp3")
|
||||
video := media.Key("BV1xx411c7mD/2")
|
||||
played := time.UnixMilli(1760000000456)
|
||||
stored := &storedMedia{items: []port.StoredMedia{
|
||||
{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Size: 12290, StoredAt: time.UnixMilli(1760000000123), LastPlayedAt: &played},
|
||||
{Key: media.Key("https://example.com/b.mp3"), Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, StoredAt: time.UnixMilli(1760000000789)},
|
||||
{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
|
||||
}}
|
||||
library := porttest.NewMediaLibrary(
|
||||
port.StoredMedia{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Artist: "周杰伦", Size: 12290, CoverKey: track.ObjectKey("link/" + key + "/cover.jpg"), StoredAt: time.UnixMilli(1760000000123)},
|
||||
port.StoredMedia{Key: other, Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, CoverURL: "https://example.com/b.jpg", StoredAt: time.UnixMilli(1760000000789), LastPlayedAt: &played},
|
||||
port.StoredMedia{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Artist: "某个 UP 主", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
|
||||
)
|
||||
store := porttest.New()
|
||||
s := services(store, store, "admin-secret")
|
||||
s.Media = media.NewLibrary(stored, stored, porttest.NewMediaStore())
|
||||
s.StorageURL = "https://alisten.example/storage/alisten"
|
||||
s.Media = media.NewLibrary(library, inRooms{{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}}, porttest.NewMediaStore())
|
||||
server, _ := start(t, s)
|
||||
admin := []string{"Authorization", "Bearer admin-secret"}
|
||||
|
||||
want := response{status: http.StatusOK, body: `{"items":[` +
|
||||
`{"key":"` + key + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"},` +
|
||||
`{"key":"` + media.Key("https://example.com/b.mp3") + `","lastPlayedAtMs":null,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
|
||||
`{"key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"}]}`}
|
||||
if got := send(t, server, http.MethodGet, "/api/admin/media", "", admin...); got != want {
|
||||
t.Errorf("列出已存储的媒体 = %#v\n期望 %#v", got, want)
|
||||
`{"artist":"某个 UP 主","coverUrl":"","key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"},` +
|
||||
`{"artist":"","coverUrl":"https://example.com/b.jpg","key":"` + other + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
|
||||
`{"artist":"周杰伦","coverUrl":"https://alisten.example/storage/alisten/link/` + key + `/cover.jpg","key":"` + key + `","lastPlayedAtMs":null,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"}],` +
|
||||
`"page":1,"pageSize":50,"total":3,"totalBytes":12293}`}
|
||||
if got := send(t, server, http.MethodPost, "/api/admin/media/queries", "{}", admin...); got != want {
|
||||
t.Errorf("按默认参数列出已存储的媒体 = %#v\n期望 %#v", got, want)
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
body string
|
||||
page int64
|
||||
want []string
|
||||
total, size int64
|
||||
}{
|
||||
{`{"sort": "stored"}`, 1, []string{video, other, key}, 3, 12293},
|
||||
{`{"sort": "idle"}`, 1, []string{video, key, other}, 3, 12293},
|
||||
{`{"sort": "size", "q": "晴天"}`, 1, []string{key, video}, 2, 12292},
|
||||
{`{"page": 2}`, 2, []string{}, 3, 12293},
|
||||
} {
|
||||
got := send(t, server, http.MethodPost, "/api/admin/media/queries", tt.body, admin...)
|
||||
var page protocol.AdminListMediaResponse
|
||||
if err := json.Unmarshal([]byte(got.body), &page); err != nil {
|
||||
t.Fatalf("%s = %#v", tt.body, got)
|
||||
}
|
||||
keys := make([]string, len(page.Items))
|
||||
for i, m := range page.Items {
|
||||
keys[i] = m.Key
|
||||
}
|
||||
if got.status != http.StatusOK || !slices.Equal(keys, tt.want) || page.Page != tt.page || page.Total != tt.total || page.TotalBytes != tt.size {
|
||||
t.Errorf("%s = %#v,期望第 %d 页 %q,共 %d 首 %d 字节", tt.body, got, tt.page, tt.want, tt.total, tt.size)
|
||||
}
|
||||
}
|
||||
|
||||
stored.used = true
|
||||
if got, want := send(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...), failure(protocol.ErrorCodeConflict, "这首歌还在房间的播放列表中,不能删除"); got != want {
|
||||
t.Errorf("删除仍被房间引用的媒体 = %#v,期望 %#v", got, want)
|
||||
if got := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+key+`"}, {"key": "not-a-key"}]}`, admin...); got.status != http.StatusBadRequest {
|
||||
t.Errorf("批量删除里有格式不对的 key = %#v,期望 400、一首也不删", got)
|
||||
}
|
||||
if got, want := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+video+`"}, {"key": "`+key+`"}, {"key": "`+media.Key("https://example.com/missing.mp3")+`"}]}`, admin...),
|
||||
(response{status: http.StatusOK, body: `{"deleted":["` + key + `"],"inUse":["` + video + `"]}`}); got != want {
|
||||
t.Errorf("批量删除 = %#v,期望 %#v", got, want)
|
||||
}
|
||||
stored.used = false
|
||||
resp, header := exchange(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...)
|
||||
checkNoContent(t, "删除媒体", resp, header)
|
||||
}
|
||||
|
||||
type playURL map[string]string
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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
|
||||
}
|
||||
@@ -87,15 +87,22 @@ func qrLogin(session port.QRLoginSession) (protocol.QRLogin, error) {
|
||||
if session.LoginURL == "" {
|
||||
return protocol.QRLogin{}, fmt.Errorf("音源没有给出二维码图片和登录链接:%w", port.ErrProviderFailed)
|
||||
}
|
||||
code, err := qr.Encode(session.LoginURL, qr.M)
|
||||
if err != nil {
|
||||
var err error
|
||||
if image, err = qrDataURI(session.LoginURL); 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}
|
||||
}
|
||||
@@ -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, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
|
||||
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))
|
||||
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)
|
||||
session, err := f.gateway.Join(t.Context(), id, m, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -252,7 +252,7 @@ func TestProfileChangeReachesRooms(t *testing.T) {
|
||||
session := decode[protocol.CreateMemberResponse](t, send(t, f.server, http.MethodPost, "/api/members", "{}"))
|
||||
created := decode[protocol.CreateRoomResponse](t, send(t, f.server, http.MethodPost, "/api/rooms", `{"name":"临时","description":""}`))
|
||||
m := member.Member{ID: session.Member.ID, Name: session.Member.Name}
|
||||
s, err := f.gateway.Join(t.Context(), created.ID, m)
|
||||
s, err := f.gateway.Join(t.Context(), created.ID, m, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,15 @@ import (
|
||||
)
|
||||
|
||||
func (s *server) getSite(ctx context.Context, _ call) (any, error) {
|
||||
name, err := s.Site.BrandName(ctx)
|
||||
settings, err := s.Site.Settings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.GetSiteResponse{BrandName: name}, nil
|
||||
githubClientID, err := s.GitHub.ClientID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen, GithubLogin: githubClientID != ""}, nil
|
||||
}
|
||||
|
||||
func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
|
||||
@@ -25,3 +29,14 @@ 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)
|
||||
}
|
||||
@@ -15,14 +15,17 @@ 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 != brandName("Alisten") {
|
||||
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("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 != brandName("周末听歌会") {
|
||||
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
|
||||
t.Errorf("修改之后 GET /api/site = %#v,期望去掉首尾空白的新名字", got)
|
||||
}
|
||||
|
||||
@@ -36,7 +39,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 != brandName("周末听歌会") {
|
||||
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
|
||||
t.Errorf("被拒绝的修改之后 GET /api/site = %#v,期望不变", got)
|
||||
}
|
||||
if logs.Len() != 0 {
|
||||
|
||||
@@ -71,8 +71,8 @@ func (s *server) websocket(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// 超时时先以 4408 关闭连接,再取消 ctx:如果先取消,正在读取第一帧的 Read 会直接断开连接,
|
||||
// 客户端收不到 4408。
|
||||
// 超时时先以 4408 关闭连接,再取消 ctx。如果先取消 ctx,正在读取第一帧的 Read 会直接断开连接,
|
||||
// 客户端就收不到 4408。
|
||||
timeout := time.AfterFunc(helloTimeout, func() {
|
||||
_ = conn.Close(protocol.CloseHandshakeTimeout, "")
|
||||
cancel()
|
||||
@@ -197,7 +197,7 @@ func (s *server) perform(ctx context.Context, sess *realtime.Session, tag reques
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 握手拒绝了 API Key,会话的成员不是 bot,房间命令总以会话的成员发出。
|
||||
// 握手时已经拒绝了 API Key,所以会话的成员不是 bot,房间命令总是以会话的成员发出。
|
||||
a, _, err := s.action(ctx, sess.Room(), sess.Member(), false, data)
|
||||
if err != nil {
|
||||
sess.Fail(tag, err)
|
||||
@@ -216,7 +216,7 @@ func (s *server) write(ctx context.Context, conn *websocket.Conn, sess *realtime
|
||||
_ = conn.Close(sessionCloseCodes[reason], "")
|
||||
return
|
||||
}
|
||||
// 写入以会话的 ctx 为期限:客户端不再读取、发送队列溢出时,阻塞的写入随会话关闭而中止。
|
||||
// 写入以会话的 ctx 为期限。这样客户端不再读取、发送队列随之溢出时,阻塞的写入会随会话关闭而中止。
|
||||
if !s.writeMessage(sess.Context(), conn, s.message(m, helloID)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
|
||||
host, gateway := realtime.New(snapshots, store, store, 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, roomID, "")
|
||||
ticket, err := f.identity.IssueTicket(t.Context(), session.Member.ID, session.MemberToken, 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 {
|
||||
|
||||
@@ -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 "准备任务失败"
|
||||
|
||||
@@ -38,7 +38,7 @@ func (t *Transcoder) ToMP3(ctx context.Context, audio port.LinkFile) (port.LinkF
|
||||
return nil, nil, fmt.Errorf("创建临时目录:%w", err)
|
||||
}
|
||||
release := func() { _ = os.RemoveAll(dir) }
|
||||
// 用 defer 是为了 panic 时也删除目录:River 会恢复任务中的 panic,worker 继续运行。
|
||||
// 用 defer 是为了 panic 时也删除目录,因为 River 会恢复任务里的 panic,worker 还会继续运行。
|
||||
done := false
|
||||
defer func() {
|
||||
if !done {
|
||||
@@ -54,7 +54,7 @@ func (t *Transcoder) ToMP3(ctx context.Context, audio port.LinkFile) (port.LinkF
|
||||
}
|
||||
|
||||
func (t *Transcoder) transcode(ctx context.Context, audio port.LinkFile, dir string) (port.LinkFile, error) {
|
||||
// 先复制成文件再交给 ffmpeg:MP4 的 moov 可能在文件末尾,从管道读不了。文件名不带扩展名,ffmpeg 只能按内容识别格式。
|
||||
// 先复制成文件再交给 ffmpeg,因为 MP4 的 moov 可能在文件末尾,从管道读不了。文件名不带扩展名,这样 ffmpeg 只能按内容识别格式。
|
||||
input := filepath.Join(dir, "input")
|
||||
switch err := copyFile(input, audio); {
|
||||
case errors.Is(err, port.ErrAudioTooLarge):
|
||||
@@ -65,8 +65,8 @@ func (t *Transcoder) transcode(ctx context.Context, audio port.LinkFile, dir str
|
||||
output := filepath.Join(dir, "audio.mp3")
|
||||
stderr, err := run(ctx, args(input, output))
|
||||
if err == nil {
|
||||
// 转码成功后立即删除输入副本,不等 release:源音频和 MP3 在上传期间仍占用临时空间,
|
||||
// 同一容器还可能并行准备其他链接。
|
||||
// 转码成功后立即删除输入副本,不等 release,因为源音频和 MP3 在上传期间还占着临时空间,
|
||||
// 同一容器还可能在并行准备其他链接。
|
||||
_ = os.Remove(input)
|
||||
return file(output), nil
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func args(input, output string) []string {
|
||||
"-map_metadata", "-1", "-map_chapters", "-1",
|
||||
// 与 yt-dlp 的 --audio-format mp3 默认质量一致。
|
||||
"-c:a", "libmp3lame", "-q:a", "5",
|
||||
// ffmpeg 写到不少于 -fs 的字节数就停止并正常退出。多一个字节,截断的输出才一定超过上限,由准备流程拒绝。
|
||||
// ffmpeg 写到不少于 -fs 的字节数就停止,并且正常退出。-fs 比上限多一个字节,截断的输出才一定超过上限,进而被准备流程拒绝。
|
||||
"-fs", strconv.Itoa(port.MaxAudioSize + 1),
|
||||
output,
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func (s *Store) ResetRoomAPIKey(ctx context.Context, roomID string, k port.NewAP
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
m, err := q.CreateMember(ctx, query.CreateMemberParams{TokenHash: k.BotTokenHash, Name: botName})
|
||||
m, err := q.CreateMember(ctx, botName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package postgres
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func newAPIKey(key string) port.NewAPIKey {
|
||||
return port.NewAPIKey{TokenHash: tokenHash(key), BotTokenHash: tokenHash("bot " + key)}
|
||||
return port.NewAPIKey{TokenHash: tokenHash(key)}
|
||||
}
|
||||
|
||||
func resetAPIKey(t *testing.T, store *Store, roomID, key string) {
|
||||
@@ -58,7 +59,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 || holder != bot {
|
||||
if holder, err := store.TicketHolder(t.Context(), tokenHash("k2")); err != nil || !reflect.DeepEqual(holder, bot) {
|
||||
t.Errorf("重置之后新 Key 的持有者 = %#v, %v,期望 bot 成员不变", holder, err)
|
||||
}
|
||||
if n := memberTickets(t, pool, bot.Member.ID); n != 1 {
|
||||
@@ -84,7 +85,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 || holder != bot {
|
||||
if holder, err := store.TicketHolder(t.Context(), tokenHash("k1")); err != nil || !reflect.DeepEqual(holder, bot) {
|
||||
t.Errorf("设置密码之后 Key 的持有者 = %#v, %v,期望 Key 仍然有效", holder, err)
|
||||
}
|
||||
if n := ticketCount(t, pool, id); n != 1 {
|
||||
@@ -119,7 +120,7 @@ func TestConcurrentFirstResetsCreateOneBot(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
|
||||
|
||||
// 先锁住房间行,两个首次生成都在等它,放开之后同时进行。
|
||||
// 先锁住房间行,让两次首次生成都等这把锁,放开之后再同时进行。
|
||||
tx, err := pool.Begin(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -150,3 +151,16 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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,6 +9,48 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// 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,6 +10,23 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const countStoredMedia = `-- name: CountStoredMedia :one
|
||||
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
|
||||
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
|
||||
`
|
||||
|
||||
type CountStoredMediaRow struct {
|
||||
Total int64
|
||||
TotalBytes int64
|
||||
}
|
||||
|
||||
func (q *Queries) CountStoredMedia(ctx context.Context, pattern string) (CountStoredMediaRow, error) {
|
||||
row := q.db.QueryRow(ctx, countStoredMedia, pattern)
|
||||
var i CountStoredMediaRow
|
||||
err := row.Scan(&i.Total, &i.TotalBytes)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteStoredMedia = `-- name: DeleteStoredMedia :exec
|
||||
DELETE FROM tracks WHERE media_key = $1::text
|
||||
`
|
||||
@@ -20,24 +37,43 @@ func (q *Queries) DeleteStoredMedia(ctx context.Context, mediaKey string) error
|
||||
}
|
||||
|
||||
const listStoredMedia = `-- name: ListStoredMedia :many
|
||||
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
|
||||
WHERE media_key IS NOT NULL
|
||||
ORDER BY prepared_at DESC, track_id
|
||||
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
|
||||
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
|
||||
ORDER BY CASE WHEN $2::text = 'stored' THEN prepared_at END DESC,
|
||||
CASE WHEN $2::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
|
||||
CASE WHEN $2::text = 'size' THEN media_size END DESC,
|
||||
track_id COLLATE "C"
|
||||
LIMIT $4::bigint OFFSET $3::bigint
|
||||
`
|
||||
|
||||
type ListStoredMediaParams struct {
|
||||
Pattern string
|
||||
Sort string
|
||||
Skip int64
|
||||
PageSize int64
|
||||
}
|
||||
|
||||
type ListStoredMediaRow struct {
|
||||
MediaKey string
|
||||
Source string
|
||||
TrackID string
|
||||
Title string
|
||||
Artist *string
|
||||
MediaSize int64
|
||||
CoverURL *string
|
||||
CoverKey *string
|
||||
PreparedAt time.Time
|
||||
LastPlayedAt *time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, error) {
|
||||
rows, err := q.db.Query(ctx, listStoredMedia)
|
||||
// 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
|
||||
func (q *Queries) ListStoredMedia(ctx context.Context, arg ListStoredMediaParams) ([]ListStoredMediaRow, error) {
|
||||
rows, err := q.db.Query(ctx, listStoredMedia,
|
||||
arg.Pattern,
|
||||
arg.Sort,
|
||||
arg.Skip,
|
||||
arg.PageSize,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -50,7 +86,9 @@ func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, er
|
||||
&i.Source,
|
||||
&i.TrackID,
|
||||
&i.Title,
|
||||
&i.Artist,
|
||||
&i.MediaSize,
|
||||
&i.CoverURL,
|
||||
&i.CoverKey,
|
||||
&i.PreparedAt,
|
||||
&i.LastPlayedAt,
|
||||
@@ -66,7 +104,7 @@ func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, er
|
||||
}
|
||||
|
||||
const storedMedia = `-- name: StoredMedia :one
|
||||
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
|
||||
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
|
||||
WHERE media_key = $1::text
|
||||
`
|
||||
|
||||
@@ -75,7 +113,9 @@ type StoredMediaRow struct {
|
||||
Source string
|
||||
TrackID string
|
||||
Title string
|
||||
Artist *string
|
||||
MediaSize int64
|
||||
CoverURL *string
|
||||
CoverKey *string
|
||||
PreparedAt time.Time
|
||||
LastPlayedAt *time.Time
|
||||
@@ -89,7 +129,9 @@ func (q *Queries) StoredMedia(ctx context.Context, mediaKey string) (StoredMedia
|
||||
&i.Source,
|
||||
&i.TrackID,
|
||||
&i.Title,
|
||||
&i.Artist,
|
||||
&i.MediaSize,
|
||||
&i.CoverURL,
|
||||
&i.CoverKey,
|
||||
&i.PreparedAt,
|
||||
&i.LastPlayedAt,
|
||||
|
||||
@@ -9,31 +9,231 @@ import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createMember = `-- name: CreateMember :one
|
||||
INSERT INTO members (token_hash, name) VALUES ($1, $2)
|
||||
RETURNING id, token_hash, name, avatar_hash, created_at
|
||||
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
|
||||
`
|
||||
|
||||
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)
|
||||
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.TokenHash,
|
||||
&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
|
||||
`
|
||||
|
||||
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
|
||||
row := q.db.QueryRow(ctx, createMember, name)
|
||||
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 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, token_hash, name, avatar_hash, created_at FROM members WHERE token_hash = $1
|
||||
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)
|
||||
`
|
||||
|
||||
func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) {
|
||||
@@ -41,38 +241,130 @@ 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
|
||||
WHERE id = $3
|
||||
RETURNING id, token_hash, name, avatar_hash, created_at
|
||||
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
|
||||
`
|
||||
|
||||
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.ID)
|
||||
row := q.db.QueryRow(ctx, updateMember,
|
||||
arg.Name,
|
||||
arg.AvatarHash,
|
||||
arg.Email,
|
||||
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,11 +9,30 @@ import (
|
||||
)
|
||||
|
||||
type Member struct {
|
||||
ID string
|
||||
TokenHash []byte
|
||||
Name string
|
||||
AvatarHash *string
|
||||
CreatedAt time.Time
|
||||
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
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
// 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,23 +9,38 @@ 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,25 +10,57 @@ import (
|
||||
)
|
||||
|
||||
const createTicket = `-- name: CreateTicket :exec
|
||||
INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES ($1, $2, $3)
|
||||
INSERT INTO room_tickets (token_hash, room_id, member_id, session_token_hash)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`
|
||||
|
||||
type CreateTicketParams struct {
|
||||
TokenHash []byte
|
||||
RoomID string
|
||||
MemberID string
|
||||
TokenHash []byte
|
||||
RoomID string
|
||||
MemberID string
|
||||
SessionTokenHash []byte
|
||||
}
|
||||
|
||||
// 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)
|
||||
_, err := q.db.Exec(ctx, createTicket,
|
||||
arg.TokenHash,
|
||||
arg.RoomID,
|
||||
arg.MemberID,
|
||||
arg.SessionTokenHash,
|
||||
)
|
||||
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
|
||||
@@ -37,7 +69,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
|
||||
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
|
||||
FROM room_tickets t
|
||||
JOIN members m ON m.id = t.member_id
|
||||
JOIN rooms r ON r.id = t.room_id
|
||||
@@ -45,11 +77,12 @@ WHERE t.token_hash = $1
|
||||
`
|
||||
|
||||
type TicketHolderRow struct {
|
||||
RoomID string
|
||||
ID string
|
||||
Name string
|
||||
AvatarHash *string
|
||||
Bot bool
|
||||
RoomID string
|
||||
ID string
|
||||
Name string
|
||||
AvatarHash *string
|
||||
Bot bool
|
||||
SessionTokenHash []byte
|
||||
}
|
||||
|
||||
func (q *Queries) TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolderRow, error) {
|
||||
@@ -61,6 +94,7 @@ 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,3 +106,57 @@ 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
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -15,16 +16,45 @@ import (
|
||||
|
||||
var _ port.MediaLibrary = (*Store)(nil)
|
||||
|
||||
func (s *Store) ListStoredMedia(ctx context.Context) ([]port.StoredMedia, error) {
|
||||
rows, err := s.q.ListStoredMedia(ctx)
|
||||
var mediaSorts = map[port.MediaSort]string{port.MediaByStored: "stored", port.MediaByIdle: "idle", port.MediaBySize: "size"}
|
||||
|
||||
// 搜索的文字按字面匹配,所以 LIKE 的通配符和转义符都要转义。
|
||||
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
|
||||
func (s *Store) ListStoredMedia(ctx context.Context, q port.MediaQuery) (port.MediaPage, error) {
|
||||
pattern := likeEscaper.Replace(q.Title)
|
||||
var page port.MediaPage
|
||||
// 总数和这一页在同一个快照里读,两者才对得上。
|
||||
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
|
||||
qtx := s.q.WithTx(tx)
|
||||
count, err := qtx.CountStoredMedia(ctx, pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page.Total, page.TotalBytes = int(count.Total), count.TotalBytes
|
||||
// 页码来自客户端,可能大到算偏移量时溢出,所以偏移量超过总数时直接返回,不再查询。
|
||||
if int64(q.Page-1) > count.Total/port.MediaPageSize {
|
||||
return nil
|
||||
}
|
||||
rows, err := qtx.ListStoredMedia(ctx, query.ListStoredMediaParams{
|
||||
Pattern: pattern,
|
||||
Sort: mediaSorts[q.Sort],
|
||||
Skip: int64(q.Page-1) * port.MediaPageSize,
|
||||
PageSize: port.MediaPageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page.Items = make([]port.StoredMedia, len(rows))
|
||||
for i, r := range rows {
|
||||
page.Items[i] = storedMedia(query.StoredMediaRow(r))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取已存储的媒体:%w", err)
|
||||
return port.MediaPage{}, fmt.Errorf("读取已存储的媒体:%w", err)
|
||||
}
|
||||
media := make([]port.StoredMedia, len(rows))
|
||||
for i, r := range rows {
|
||||
media[i] = storedMedia(query.StoredMediaRow(r))
|
||||
}
|
||||
return media, nil
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *Store) StoredMedia(ctx context.Context, key string) (port.StoredMedia, bool, error) {
|
||||
@@ -51,7 +81,9 @@ func storedMedia(r query.StoredMediaRow) port.StoredMedia {
|
||||
Source: track.Source(r.Source),
|
||||
Link: r.TrackID,
|
||||
Title: r.Title,
|
||||
Artist: orEmpty(r.Artist),
|
||||
Size: r.MediaSize,
|
||||
CoverURL: orEmpty(r.CoverURL),
|
||||
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
|
||||
StoredAt: r.PreparedAt.UTC(),
|
||||
LastPlayedAt: utc(r.LastPlayedAt),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -10,10 +15,11 @@ import (
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key, cover string, size int64) {
|
||||
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key string, meta track.Meta, size int64) {
|
||||
t.Helper()
|
||||
meta.Title, meta.PageURL = "标题 "+key, pageURL
|
||||
p := port.PreparedTrack{
|
||||
Meta: track.Meta{Title: "标题 " + key, CoverKey: track.ObjectKey(cover), PageURL: pageURL},
|
||||
Meta: meta,
|
||||
Result: track.Prepared{Duration: time.Minute, Gain: 1},
|
||||
MediaKey: key,
|
||||
MediaSize: size,
|
||||
@@ -27,9 +33,9 @@ func TestStoredMedia(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
a := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
|
||||
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
|
||||
saveStored(t, store, a, a.ID, "aaa", "link/aaa/cover.jpg", 100)
|
||||
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", "", 200)
|
||||
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", "link/ccc/cover.jpg", 300)
|
||||
saveStored(t, store, a, a.ID, "aaa", track.Meta{Artist: "周杰伦", CoverKey: "link/aaa/cover.jpg"}, 100)
|
||||
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", track.Meta{CoverURL: "https://example.com/b.jpg"}, 200)
|
||||
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", track.Meta{Artist: "某个 UP 主", CoverKey: "link/ccc/cover.jpg"}, 300)
|
||||
addTrack(t, store, netease("1"), track.Meta{Title: "晴天"}, track.Prepared{Duration: time.Minute, Gain: 1})
|
||||
|
||||
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
|
||||
@@ -37,22 +43,23 @@ func TestStoredMedia(t *testing.T) {
|
||||
played := room.Entry{ID: 1, Track: a, By: alice}
|
||||
saveRoom(t, store, term, snap, room.RecordPlay{Entry: played, StartedAt: t0, EndedAt: t0.Add(time.Minute)})
|
||||
|
||||
list, err := store.ListStoredMedia(t.Context())
|
||||
page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list := page.Items
|
||||
if len(list) != 3 || list[0].Key != "ccc" || list[1].Key != "bbb" || list[2].Key != "aaa" {
|
||||
t.Fatalf("已存储的媒体 = %+v,期望两首链接和一首 B 站的歌,后存入的在前", list)
|
||||
}
|
||||
got := list[2]
|
||||
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Size != 100 || got.CoverKey != "link/aaa/cover.jpg" ||
|
||||
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Artist != "周杰伦" || got.Size != 100 || got.CoverURL != "" || got.CoverKey != "link/aaa/cover.jpg" ||
|
||||
got.StoredAt.IsZero() || got.LastPlayedAt == nil || !got.LastPlayedAt.Equal(t0) {
|
||||
t.Errorf("播放过的链接 = %+v", got)
|
||||
}
|
||||
if b := list[1]; b.Source != track.Link || b.CoverKey != "" || b.LastPlayedAt != nil {
|
||||
t.Errorf("没有封面、没有播放过的链接 = %+v", b)
|
||||
if b := list[1]; b.Source != track.Link || b.Artist != "" || b.CoverURL != "https://example.com/b.jpg" || b.CoverKey != "" || b.LastPlayedAt != nil {
|
||||
t.Errorf("没有歌手、封面不在对象存储里、没有播放过的链接 = %+v", b)
|
||||
}
|
||||
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
|
||||
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.Artist != "某个 UP 主" || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
|
||||
t.Errorf("B 站的歌 = %+v", c)
|
||||
}
|
||||
|
||||
@@ -74,7 +81,110 @@ func TestStoredMedia(t *testing.T) {
|
||||
t.Errorf("清除之后 %v 的准备结果 = %v, %v,期望没有", tr, ok, err)
|
||||
}
|
||||
}
|
||||
if list, err := store.ListStoredMedia(t.Context()); err != nil || len(list) != 1 {
|
||||
t.Errorf("清除之后的列表 = %+v, %v", list, err)
|
||||
if page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored}); err != nil || len(page.Items) != 1 || page.Total != 1 {
|
||||
t.Errorf("清除之后的列表 = %+v, %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mediaKeys(items []port.StoredMedia) []string {
|
||||
keys := make([]string, len(items))
|
||||
for i, m := range items {
|
||||
keys[i] = m.Key
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// 共 120 首。存储时间和最近播放时间各不相同,其中 10 首没有播放过。每 3 首大小相同,要靠曲目 ID 分出先后。
|
||||
// 有 3 首的标题含 50%_off。另外两首只在 % 或 _ 被当作通配符时才会匹配 50%_off。
|
||||
func TestStoredMediaPages(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
titles := map[int]string{5: "Summer 50%_off", 50: "50%_OFF 特价", 95: "全场50%_Off", 20: "50abc_off", 30: "50%Xoff", 40: `C:\music`}
|
||||
all := make([]port.StoredMedia, 120)
|
||||
var totalBytes int64
|
||||
for i := range all {
|
||||
// 曲目 ID 的顺序和存入的顺序不同,这样大小相同时有没有按曲目 ID 排才看得出来。
|
||||
link := fmt.Sprintf("https://example.com/%03d.mp3", i*7%120)
|
||||
m := port.StoredMedia{
|
||||
Key: fmt.Sprintf("key%03d", i), Source: track.Link, Link: link, Title: cmp.Or(titles[i], fmt.Sprintf("第 %d 首", i)),
|
||||
Size: int64(1000 + i*11%40), StoredAt: t0.Add(time.Duration(i*13%120) * time.Minute),
|
||||
}
|
||||
if i%12 != 0 {
|
||||
played := t0.Add(time.Duration(i*17%120) * time.Hour)
|
||||
m.LastPlayedAt = &played
|
||||
}
|
||||
saveStored(t, store, track.Track{Source: track.Link, ID: link}, link, m.Key, track.Meta{}, m.Size)
|
||||
if _, err := pool.Exec(t.Context(), "UPDATE tracks SET title = $1, prepared_at = $2, last_played_at = $3 WHERE media_key = $4", m.Title, m.StoredAt, m.LastPlayedAt, m.Key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all[i] = m
|
||||
totalBytes += m.Size
|
||||
}
|
||||
list := func(q port.MediaQuery) port.MediaPage {
|
||||
t.Helper()
|
||||
page, err := store.ListStoredMedia(t.Context(), q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return page
|
||||
}
|
||||
// 每一页都报告全部的数量和大小。翻过最后一页得到的是空页。
|
||||
pages := func(sort port.MediaSort) []port.StoredMedia {
|
||||
t.Helper()
|
||||
var items []port.StoredMedia
|
||||
for i, want := range []int{50, 50, 20, 0} {
|
||||
page := list(port.MediaQuery{Page: i + 1, Sort: sort})
|
||||
if len(page.Items) != want || page.Total != 120 || page.TotalBytes != totalBytes {
|
||||
t.Fatalf("排序 %d 的第 %d 页有 %d 首,共 %d 首 %d 字节,期望 %d 首,共 120 首 %d 字节", sort, i+1, len(page.Items), page.Total, page.TotalBytes, want, totalBytes)
|
||||
}
|
||||
items = append(items, page.Items...)
|
||||
}
|
||||
if got, want := mediaKeys(items), mediaKeys(all); !slices.Equal(got, want) {
|
||||
t.Fatalf("排序 %d 依次翻页 = %q,期望不重复也不遗漏", sort, got)
|
||||
}
|
||||
return items
|
||||
}
|
||||
inOrder := func(name string, items []port.StoredMedia, compare func(a, b port.StoredMedia) int) {
|
||||
t.Helper()
|
||||
for i := 1; i < len(items); i++ {
|
||||
if compare(items[i-1], items[i]) >= 0 {
|
||||
t.Fatalf("%s:%+v 排在了 %+v 之前", name, items[i-1], items[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
byLink := func(a, b port.StoredMedia) int { return strings.Compare(a.Link, b.Link) }
|
||||
|
||||
inOrder("按存储时间", pages(port.MediaByStored), func(a, b port.StoredMedia) int { return b.StoredAt.Compare(a.StoredAt) })
|
||||
idle := pages(port.MediaByIdle)
|
||||
for i, m := range idle {
|
||||
if (m.LastPlayedAt == nil) != (i < 10) {
|
||||
t.Fatalf("按最近播放的第 %d 首 = %+v,期望前 10 首是没有播放过的", i+1, m)
|
||||
}
|
||||
}
|
||||
inOrder("没有播放过的", idle[:10], byLink)
|
||||
inOrder("按最近播放", idle[10:], func(a, b port.StoredMedia) int { return a.LastPlayedAt.Compare(*b.LastPlayedAt) })
|
||||
inOrder("按大小", pages(port.MediaBySize), func(a, b port.StoredMedia) int { return cmp.Or(cmp.Compare(b.Size, a.Size), byLink(a, b)) })
|
||||
if page := list(port.MediaQuery{Page: math.MaxInt, Sort: port.MediaBySize}); len(page.Items) != 0 || page.Total != 120 || page.TotalBytes != totalBytes {
|
||||
t.Errorf("页码极大时 = %d 首,共 %d 首 %d 字节,期望没有,总数照常", len(page.Items), page.Total, page.TotalBytes)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
title string
|
||||
want []int
|
||||
}{
|
||||
{"50%_off", []int{5, 50, 95}},
|
||||
{"OFF", []int{5, 20, 30, 50, 95}},
|
||||
{`:\m`, []int{40}},
|
||||
} {
|
||||
var want []port.StoredMedia
|
||||
var bytes int64
|
||||
for _, i := range tt.want {
|
||||
want = append(want, all[i])
|
||||
bytes += all[i].Size
|
||||
}
|
||||
page := list(port.MediaQuery{Page: 1, Sort: port.MediaByStored, Title: tt.title})
|
||||
if got := mediaKeys(page.Items); !slices.Equal(got, mediaKeys(want)) || page.Total != len(want) || page.TotalBytes != bytes {
|
||||
t.Errorf("搜索 %q = %q,共 %d 首 %d 字节,期望 %q,共 %d 首 %d 字节", tt.title, got, page.Total, page.TotalBytes, mediaKeys(want), len(want), bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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"
|
||||
@@ -15,11 +16,20 @@ import (
|
||||
var _ port.Members = (*Store)(nil)
|
||||
|
||||
func (s *Store) CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error) {
|
||||
row, err := s.q.CreateMember(ctx, query.CreateMemberParams{TokenHash: tokenHash, Name: name})
|
||||
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})
|
||||
})
|
||||
if err != nil {
|
||||
return member.Member{}, fmt.Errorf("创建成员:%w", err)
|
||||
}
|
||||
return memberOf(row), nil
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error) {
|
||||
@@ -33,8 +43,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 *string) (member.Member, error) {
|
||||
row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash})
|
||||
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})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = port.ErrMemberNotFound
|
||||
}
|
||||
@@ -47,3 +57,229 @@ func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *s
|
||||
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,6 +5,9 @@ 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"
|
||||
)
|
||||
@@ -59,13 +62,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")); err != nil {
|
||||
if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil); 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(""))
|
||||
got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -78,14 +81,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)
|
||||
got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil, 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"))
|
||||
got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -96,7 +99,521 @@ 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); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil, 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("补设之后应只留下发出请求的会话")
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,10 @@ import (
|
||||
|
||||
const migrationLock int64 = 0x616c697374656e
|
||||
|
||||
// 锁所在的事务一直占用一个连接,fn 中的迁移要使用连接池中的其他连接。
|
||||
// 锁所在的事务一直占着一个连接,所以 fn 里的迁移要用连接池里的其他连接。
|
||||
func WithMigrationLock(ctx context.Context, pool *pgxpool.Pool, fn func(context.Context) error) error {
|
||||
return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
|
||||
// 这个事务在整个迁移期间都处于空闲状态,数据库设置的空闲事务超时或事务超时会断开它,锁随之失效。
|
||||
// 这个事务在整个迁移期间都处于空闲状态,数据库设置的空闲事务超时或事务超时会断开这个事务,锁也就随之失效。
|
||||
// 后到的迁移要一直等待锁,而数据库设置的 lock_timeout、statement_timeout 和 transaction_timeout 会让它报错而不是等待。
|
||||
if _, err := tx.Exec(ctx, `SELECT set_config('idle_in_transaction_session_timeout', '0', true),
|
||||
set_config('lock_timeout', '0', true), set_config('statement_timeout', '0', true),
|
||||
@@ -52,7 +52,7 @@ func MigrateSchema(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger)
|
||||
}
|
||||
|
||||
func MigrateRiver(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error {
|
||||
// rivermigrate 的日志带有 version 字段,而 slog 会把重名的字段写两次:放进 river 分组,它才不会与日志的固定字段 version 重名。
|
||||
// rivermigrate 的日志带有 version 字段,而 slog 会把重名的字段写两次。放进 river 分组,rivermigrate 的 version 才不会和日志的固定字段 version 重名。
|
||||
migrator, err := rivermigrate.New(riverpgxv5.New(pool), &rivermigrate.Config{Logger: logger.WithGroup("river")})
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 River 迁移器:%w", err)
|
||||
|
||||
@@ -11,6 +11,11 @@ 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 {
|
||||
@@ -61,7 +66,7 @@ func TestMigrateWaitsForMigrationLock(t *testing.T) {
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := newDatabase(t, "template1")
|
||||
// 先完成迁移:等到锁之后的迁移只剩检查,不会被 statement_timeout 打断。
|
||||
// 先完成迁移,这样等到锁之后的迁移只剩检查,不会被 statement_timeout 打断。
|
||||
if err := migrate(t.Context(), connect(t, cfg), discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -128,3 +133,95 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/dihemoe/alisten/db/migrations"
|
||||
)
|
||||
|
||||
// 只读取版本表:goose 的 HasPending 在缺少版本表时会建表,就绪检查不能修改数据库。
|
||||
// 只读取版本表,不用 goose 的 HasPending,因为 HasPending 在缺少版本表时会建表,而就绪检查不能修改数据库。
|
||||
func CheckMigrations(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
db := stdlib.OpenDBFromPool(pool)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
@@ -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 (token_hash, name) VALUES (sha256(gen_random_uuid()::text::bytea), '甲')
|
||||
INSERT INTO members (name) VALUES ('甲')
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO room_tickets (token_hash, room_id, member_id) SELECT sha256(gen_random_uuid()::text::bytea), $1, id FROM m`,
|
||||
|
||||
@@ -12,20 +12,27 @@ import (
|
||||
|
||||
var _ port.Site = (*Store)(nil)
|
||||
|
||||
func (s *Store) BrandName(ctx context.Context) (string, error) {
|
||||
name, err := s.q.BrandName(ctx)
|
||||
func (s *Store) Settings(ctx context.Context) (port.SiteSettings, error) {
|
||||
row, err := s.q.SiteSettings(ctx)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
return port.SiteSettings{RegistrationOpen: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取品牌名:%w", err)
|
||||
return port.SiteSettings{}, fmt.Errorf("读取站点设置:%w", err)
|
||||
}
|
||||
return name, nil
|
||||
return port.SiteSettings{BrandName: orEmpty(row.BrandName), RegistrationOpen: row.RegistrationOpen}, 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
|
||||
}
|
||||
@@ -1,20 +1,34 @@
|
||||
package postgres
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
func TestBrandName(t *testing.T) {
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
func TestSiteSettings(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
if name, err := store.BrandName(t.Context()); err != nil || name != "" {
|
||||
t.Fatalf("没有设置过时 = %q, %v,期望空字符串", name, err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"周末听歌会", "Alisten"} {
|
||||
if err := store.SetBrandName(t.Context(), want); err != nil {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := store.BrandName(t.Context()); err != nil || got != want {
|
||||
t.Errorf("BrandName = %q, %v,期望 %q", got, err, want)
|
||||
}
|
||||
check(port.SiteSettings{BrandName: name, RegistrationOpen: false}, "修改品牌名不影响注册开关")
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,13 +6,17 @@ 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)
|
||||
var (
|
||||
_ port.Tickets = (*Store)(nil)
|
||||
_ port.ExistingTickets = (*Store)(nil)
|
||||
)
|
||||
|
||||
func (s *Store) CreateTicket(ctx context.Context, t port.NewTicket) error {
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
@@ -27,10 +31,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})
|
||||
return q.CreateTicket(ctx, query.CreateTicketParams{TokenHash: t.TokenHash, RoomID: t.RoomID, MemberID: t.MemberID, SessionTokenHash: t.SessionTokenHash})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("签发房间 %s 的票据:%w", t.RoomID, err)
|
||||
return fmt.Errorf("签发房间 %s 的票据:%w", t.RoomID, sessionError(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -44,8 +48,27 @@ 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,
|
||||
RoomID: row.RoomID,
|
||||
Member: member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)},
|
||||
Bot: row.Bot,
|
||||
SessionTokenHash: row.SessionTokenHash,
|
||||
}, 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
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package postgres
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -16,15 +18,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}); err != nil {
|
||||
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID, SessionTokenHash: tokenHash("alice")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := store.TicketHolder(t.Context(), tokenHash("t1"))
|
||||
if want := (port.TicketHolder{RoomID: id, Member: alice}); err != nil || got != want {
|
||||
if want := (port.TicketHolder{RoomID: id, Member: alice, SessionTokenHash: tokenHash("alice")}); err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("TicketHolder = %#v, %v,期望 %#v", got, err, want)
|
||||
}
|
||||
|
||||
renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil)
|
||||
renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -37,6 +39,111 @@ 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", "甲")
|
||||
|
||||
@@ -13,7 +13,10 @@ import (
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var _ port.Tracks = (*Store)(nil)
|
||||
var (
|
||||
_ port.Tracks = (*Store)(nil)
|
||||
_ port.TrackLookup = (*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})
|
||||
@@ -27,6 +30,19 @@ 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),
|
||||
@@ -53,9 +69,12 @@ 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) {
|
||||
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}
|
||||
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}
|
||||
}
|
||||
|
||||
func orZero(p *int64) int64 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -124,3 +125,42 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
const playlistDetailAPI = "https://i.y.qq.com/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg"
|
||||
|
||||
type playlistDetailResponse struct {
|
||||
Code int `json:"code"`
|
||||
Cdlist []struct {
|
||||
Songlist []struct {
|
||||
SongMID string `json:"songmid"`
|
||||
SongName string `json:"songname"`
|
||||
AlbumName string `json:"albumname"`
|
||||
AlbumMID string `json:"albummid"`
|
||||
Interval int64 `json:"interval"`
|
||||
Singer []singer `json:"singer"`
|
||||
} `json:"songlist"`
|
||||
} `json:"cdlist"`
|
||||
}
|
||||
|
||||
func (r playlistDetailResponse) check() error {
|
||||
if r.Code != 0 {
|
||||
return fmt.Errorf("QQ 音乐返回错误码 %d", r.Code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) PlaylistTracks(ctx context.Context, playlistID string) ([]port.Song, error) {
|
||||
return cached(c, "playlist_tracks "+playlistID, func() ([]port.Song, error) {
|
||||
var resp playlistDetailResponse
|
||||
query := url.Values{
|
||||
"type": {"1"}, "json": {"1"}, "utf8": {"1"}, "onlysong": {"0"}, "disstid": {playlistID},
|
||||
"format": {"json"}, "g_tk": {"5381"}, "loginUin": {"0"}, "hostUin": {"0"},
|
||||
"inCharset": {"utf8"}, "outCharset": {"utf-8"}, "notice": {"0"}, "platform": {"yqq"}, "needNewCode": {"0"},
|
||||
}
|
||||
if err := c.get(ctx, "qq", "playlist_tracks", playlistDetailAPI, query, desktopHeader, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Cdlist) == 0 {
|
||||
return nil, errors.New("QQ 音乐的歌单不存在或不可见")
|
||||
}
|
||||
// 这个接口一次返回全部曲目,实测 1137 首的歌单也完整,不需要翻页。
|
||||
songs := make([]port.Song, 0, len(resp.Cdlist[0].Songlist))
|
||||
for _, s := range resp.Cdlist[0].Songlist {
|
||||
// 没有 songmid 的条目是下架等不可用的歌,丢弃但保持其余歌曲的原顺序。
|
||||
if s.SongMID == "" {
|
||||
continue
|
||||
}
|
||||
songs = append(songs, song(s.SongMID, s.SongName, s.Singer, s.AlbumName, s.AlbumMID, s.Interval))
|
||||
}
|
||||
return songs, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
func TestPlaylistTracks(t *testing.T) {
|
||||
c, obs := serve(t, map[string]http.HandlerFunc{
|
||||
"/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg": func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("disstid"); got != "7382629476" {
|
||||
t.Errorf("歌单 ID = %q,期望与搜索返回的 dissid 一致", got)
|
||||
}
|
||||
fixture(t, "playlist-tracks.json")(w, r)
|
||||
},
|
||||
})
|
||||
want := []port.Song{
|
||||
{
|
||||
Track: track.Track{Source: track.QQ, ID: "mid1"},
|
||||
Meta: track.Meta{
|
||||
Title: "第一首", Artist: "歌手甲, 歌手乙", Album: "专辑一",
|
||||
CoverURL: "https://y.gtimg.cn/music/photo_new/T002R300x300M000album1.jpg",
|
||||
PageURL: "https://y.qq.com/n/ryqq/songDetail/mid1",
|
||||
Duration: 200 * time.Second,
|
||||
},
|
||||
},
|
||||
{
|
||||
Track: track.Track{Source: track.QQ, ID: "mid2"},
|
||||
Meta: track.Meta{
|
||||
Title: "第二首", Artist: "歌手丙", Album: "专辑二",
|
||||
CoverURL: "https://y.gtimg.cn/music/photo_new/T002R300x300M000album2.jpg",
|
||||
PageURL: "https://y.qq.com/n/ryqq/songDetail/mid2",
|
||||
Duration: 100 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
// 缓存命中时第二次调用不再发请求。
|
||||
for range 2 {
|
||||
got, err := c.PlaylistTracks(t.Context(), "7382629476")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("歌单内的歌曲 = %#v\n期望 %#v", got, want)
|
||||
}
|
||||
}
|
||||
obs.check(t, "qq playlist_tracks")
|
||||
}
|
||||
|
||||
func TestPlaylistTracksUnavailable(t *testing.T) {
|
||||
for name, file := range map[string]string{
|
||||
"接口返回错误码": "playlist-tracks-missing.json",
|
||||
"cdlist 为空": "playlist-tracks-empty.json",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
c, _ := serve(t, map[string]http.HandlerFunc{
|
||||
"/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg": fixture(t, file),
|
||||
})
|
||||
if _, err := c.PlaylistTracks(t.Context(), "999999999999"); err == nil {
|
||||
t.Error("不存在或不可见的歌单没有返回错误")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"code":0,"subcode":0,"cdnum":0,"cdlist":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"code":10,"subcode":0,"cdnum":0,"cdlist":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"code":0,"subcode":0,"cdnum":1,"cdlist":[{"dissname":"测试歌单","songlist":[{"songmid":"mid1","songname":"第一首","albumname":"专辑一","albummid":"album1","interval":200,"singer":[{"name":"歌手甲"},{"name":"歌手乙"}]},{"songmid":"","songname":"已下架的歌","albumname":"","albummid":"","interval":0,"singer":[]},{"songmid":"mid2","songname":"第二首","albumname":"专辑二","albummid":"album2","interval":100,"singer":[{"name":"歌手丙"}]}]}]}
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
// 任务载荷是跨版本契约:新版本必须能执行旧版本留下的任务。载荷包含曲目的 Source 和 ID。
|
||||
// 适配器之间不能互相 import,internal/adapter/in/worker 按相同的 kind 和字段解码。
|
||||
// 任务载荷是跨版本的契约,新版本必须能执行旧版本留下的任务。载荷包含曲目的 Source 和 ID。
|
||||
// 适配器之间不能互相 import,所以 internal/adapter/in/worker 要按相同的 kind 和字段解码。
|
||||
type prepareArgs struct {
|
||||
Source string `json:"source"`
|
||||
ID string `json:"id"`
|
||||
@@ -35,7 +35,7 @@ func (a prepareArgs) InsertOpts() river.InsertOpts {
|
||||
return river.InsertOpts{
|
||||
Queue: queueOf(track.Source(a.Source)),
|
||||
MaxAttempts: 2,
|
||||
// 去重不包括已完成的任务:曲目表中的结果被清除之后,River 保留的已完成任务不能阻止重新准备。
|
||||
// 去重不包括已完成的任务,因为曲目表里的结果被清除之后,River 保留的已完成任务不能阻止重新准备。
|
||||
UniqueOpts: river.UniqueOpts{
|
||||
ByArgs: true,
|
||||
ByState: []rivertype.JobState{
|
||||
@@ -46,7 +46,7 @@ func (a prepareArgs) InsertOpts() river.InsertOpts {
|
||||
}
|
||||
}
|
||||
|
||||
// 链接和 B 站的歌由 worker 准备:只有 worker 的镜像里有 yt-dlp 和 ffmpeg。
|
||||
// 链接和 B 站的歌由 worker 准备,因为只有 worker 的镜像里有 yt-dlp 和 ffmpeg。
|
||||
func queueOf(source track.Source) string {
|
||||
switch source {
|
||||
case track.Link:
|
||||
@@ -75,7 +75,7 @@ func (q *Queue) Submit(ctx context.Context, t track.Track) (int64, error) {
|
||||
return res.Job.ID, nil
|
||||
}
|
||||
|
||||
// 已被 River 清理的任务当作已完成:成功的结果在曲目表中,失败的任务不会再执行。
|
||||
// 已被 River 清理的任务当作已完成,因为成功的结果在曲目表里,失败的任务也不会再执行。
|
||||
func (q *Queue) Status(ctx context.Context, jobID int64) (port.JobState, string, error) {
|
||||
job, err := q.client.JobGet(ctx, jobID)
|
||||
if errors.Is(err, river.ErrNotFound) {
|
||||
@@ -95,7 +95,7 @@ func (q *Queue) Status(ctx context.Context, jobID int64) (port.JobState, string,
|
||||
return port.JobPending, "", nil
|
||||
}
|
||||
|
||||
// River 记录取消的原因时带有 "JobCancelError: " 前缀;internal/adapter/in/worker 以同样的方式提取任务失败的原因。
|
||||
// River 记录取消的原因时带有 "JobCancelError: " 前缀。internal/adapter/in/worker 用同样的方式提取任务失败的原因。
|
||||
func reason(job *rivertype.JobRow) string {
|
||||
if len(job.Errors) == 0 {
|
||||
return "准备任务失败"
|
||||
|
||||
Loaded 100 of 476 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user