Compare commits

..
21 Commits
Author SHA1 Message Date
DiheChen e539c7d78b feat: select several playlist songs to remove, save or reorder them and drag songs into place
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-09 21:33:06 +08:00
DiheChen d73a161e84 feat: browse my playlists as a sortable list or a glowing cover grid and open each one 2026-11-09 21:32:21 +08:00
DiheChen ffd2b390f0 feat: save songs to my playlists from the player, queue and history 2026-11-09 21:31:18 +08:00
DiheChen ef4558bd65 feat: track member playlists in the web client 2026-11-09 21:30:19 +08:00
DiheChen c87b48f014 feat: tell which of my playlists contain a song 2026-11-09 21:29:43 +08:00
DiheChen 4a59c639cd feat: remove, copy and reorder several playlist songs at once 2026-11-09 21:28:12 +08:00
DiheChen a07ef81e3c feat: expose member playlists over HTTP 2026-11-09 21:27:41 +08:00
DiheChen ca6603a994 feat: manage member playlists with server-side track metadata 2026-11-09 21:26:41 +08:00
DiheChen 224ea02725 feat: store playlists with a fixed favorites list for every account 2026-11-09 21:25:18 +08:00
DiheChen f4e4d23831 feat: bind room tickets to member sessions and disconnect revoked sessions 2026-11-09 21:24:32 +08:00
DiheChen 83afee3142 feat: register and sign in from the settings page 2026-11-09 21:23:23 +08:00
DiheChen ec62be2d8d feat: switch registration and reset account passwords in the admin page 2026-11-09 21:22:40 +08:00
DiheChen 872904975d feat: expose account registration, login and admin recovery over HTTP 2026-11-09 21:21:23 +08:00
DiheChen b50e645d30 feat: register, log in and manage member accounts 2026-11-09 21:20:12 +08:00
DiheChen 7f67df3d41 feat: store whether registration is open 2026-11-09 21:19:37 +08:00
DiheChen a61df45c32 feat: store accounts and per-device sessions for members 2026-11-09 21:18:26 +08:00
DiheChen 90a3b26c77 feat: reject emails that are not shaped like name@example.com when updating a profile 2026-11-09 21:17:42 +08:00
DiheChen 729e5861f5 feat: take the client address from trusted proxies 2026-11-09 21:16:26 +08:00
DiheChen d17477ba59 feat: add a shared quota for account operations 2026-11-09 21:15:06 +08:00
DiheChen 97764f9769 docs: refine the open API documentation 2026-11-08 01:26:55 +08:00
DiheChen 47908cedcf ci: call CD only for tag builds
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-07 22:46:14 +08:00
164 changed files with 13083 additions and 393 deletions

No files matched your search

+1
View File
@@ -2,6 +2,7 @@
ALISTEN_HOST=127.0.0.1 # 绑定的 ip
ALISTEN_PORT=9876 # 监听的端口
ALISTEN_ALLOWED_ORIGINS="" # 逗号分隔的 Origin 允许列表,留空时仅允许 ALISTEN_PUBLIC_URL
ALISTEN_TRUSTED_PROXIES="" # 逗号分隔的 IP 地址或 CIDR,来自这些地址的反向代理传来的 X-Forwarded-For 才被采信;留空时,compose.prod.yaml 信任 proxy 网络 172.31.250.0/24,其他情况按连接的对端地址限流
ALISTEN_LOG_LEVEL=info # 日志等级, 可选值为 debug、info、warn、error
ALISTEN_WORKER_TMP_SIZE="1g" # Worker 临时文件大小
ALISTEN_ADMIN_TOKEN="" # 进入管理后台需要的凭证, 留空则禁用后台
+6 -9
View File
@@ -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
+10
View File
@@ -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
+31
View File
@@ -39,6 +39,18 @@ vim .env
# 然后使用原来的部署命令重新启动
```
`proxy` 网络已经存在时,docker compose 不会按 `compose.prod.yaml` 修改它的网段。如果下面的命令输出的不是 `172.31.250.0/24`(比如网络由没有固定网段的旧版创建),serve 就不会信任 traefik 转发的客户端地址,所有客户端会被算进同一个限流额度。这时先停止服务、删除这个网络,再用原来的部署命令启动:
```bash
docker network inspect proxy --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
docker compose \
-f compose.yaml \
-f compose.prod.yaml \
down
# down 已经删掉这个网络时,下面的命令会提示网络不存在,可以忽略
docker network rm proxy
```
修改源代码后,自行构建和启动:
```bash
@@ -78,6 +90,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` | 发送房间聊天消息 |
## 使用与版权
本项目不提供音源内容及其使用授权。音源名称、音频、封面和歌词等内容的权利归各自权利人所有。
+5
View File
@@ -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))
}
+36 -3
View File
@@ -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"`
@@ -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 {
@@ -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")
+22 -6
View File
@@ -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{}))}
+22
View File
@@ -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)
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+7
View File
@@ -53,6 +53,7 @@ services:
environment:
ALISTEN_PUBLIC_URL: https://${ALISTEN_DOMAIN}
STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL}
ALISTEN_TRUSTED_PROXIES: ${ALISTEN_TRUSTED_PROXIES:-172.31.250.0/24}
networks:
- backend
- db
@@ -115,6 +116,12 @@ services:
networks:
proxy:
name: proxy
# traefik 经这个网络转发请求,serve 默认信任这个网段传来的 X-Forwarded-For。与已有的 docker 网络冲突时改这里,并同步修改 ALISTEN_TRUSTED_PROXIES。
# 网络已经存在时 docker compose 不会按这里改它的网段:proxy 网络不是这个网段时(比如由没有固定网段的旧版创建),
# 先 down 停止服务,再 docker network rm proxy 删除这个网络,然后启动。
ipam:
config:
- subnet: 172.31.250.0/24
backend:
name: alisten-backend
db:
+1
View File
@@ -40,6 +40,7 @@ services:
<<: *alisten-env
ALISTEN_PUBLIC_URL: ${ALISTEN_PUBLIC_URL}
ALISTEN_ALLOWED_ORIGINS: ${ALISTEN_ALLOWED_ORIGINS}
ALISTEN_TRUSTED_PROXIES: ${ALISTEN_TRUSTED_PROXIES:-}
ALISTEN_ADMIN_TOKEN: ${ALISTEN_ADMIN_TOKEN}
STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL}
GOMAXPROCS: "2"
+39
View File
@@ -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 CHECK (password_hash IS NULL OR username IS NOT NULL),
ADD COLUMN email text,
ADD CHECK (email IS NULL OR username IS NOT NULL);
-- site 表没有行或者 brand_name 为 NULL 时,品牌名是默认的 Alisten;没有行时开放注册。
ALTER TABLE site
ALTER COLUMN brand_name DROP NOT NULL,
ADD COLUMN registration_open boolean NOT NULL DEFAULT true;
-- 票据属于签发它的会话,会话删除时票据随之失效。bot 的 API Key 票据没有会话。
ALTER TABLE room_tickets ADD COLUMN session_token_hash bytea REFERENCES member_sessions ON DELETE CASCADE;
CREATE INDEX ON room_tickets (session_token_hash);
UPDATE room_tickets t SET session_token_hash = s.token_hash
FROM member_sessions s
WHERE s.member_id = t.member_id;
+35
View File
@@ -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);
+41 -4
View File
@@ -1,14 +1,51 @@
-- 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 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 FROM members WHERE id = @id;
-- name: RegisterMember :execrows
-- 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL;
-- name: AccountByUsername :one
SELECT * FROM members WHERE username = @username;
-- 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 SHARE 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR SHARE;
-- name: DeleteSession :execrows
DELETE FROM member_sessions WHERE token_hash = @token_hash;
-- 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;
+106
View File
@@ -0,0 +1,106 @@
-- 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, 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: 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;
+6 -2
View File
@@ -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;
+4 -2
View File
@@ -3,10 +3,12 @@
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
+2
View File
@@ -63,6 +63,8 @@ spec:
value: {{ printf "https://%s" (include "alisten.domain" .) | quote }}
- name: ALISTEN_ALLOWED_ORIGINS
value: {{ .Values.allowedOrigins | quote }}
- name: ALISTEN_TRUSTED_PROXIES
value: {{ .Values.trustedProxies | quote }}
- name: STORAGE_PUBLIC_URL
value: {{ required "storage.publicUrl 不能为空" .Values.storage.publicUrl | quote }}
{{- include "alisten.databaseEnv" . | nindent 12 }}
+2
View File
@@ -1,5 +1,7 @@
domain: ""
allowedOrigins: ""
# 逗号分隔的 CIDR:只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信,用于按客户端 IP 限流。
trustedProxies: ""
logLevel: info
otlpEndpoint: ""
database:
+79
View File
@@ -0,0 +1,79 @@
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, 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, err := s.Accounts.Login(ctx, c.member, bearer(c.r), c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
return protocol.LoginResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account)}, 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 accountDTO(a identity.Account) protocol.Account {
dto := protocol.Account{Username: a.Username}
if a.Email != "" {
dto.Email = &a.Email
}
return dto
}
+72 -16
View File
@@ -8,6 +8,7 @@ import (
"io"
"log/slog"
"net/http"
"net/netip"
"runtime/debug"
"slices"
"strings"
@@ -21,32 +22,41 @@ import (
"github.com/dihemoe/alisten/internal/app/identity"
"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)
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
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
StorageURL string
// 只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信。
TrustedProxies []netip.Prefix
}
type server struct {
@@ -63,8 +73,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 +110,20 @@ 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.handle(mux, protocol.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -125,6 +153,10 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
srv.handle(mux, protocol.RouteAdminOpenRegistration, srv.adminOpenRegistration)
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
srv.handle(mux, protocol.RouteAdminGetAccount, srv.adminGetAccount)
srv.handle(mux, protocol.RouteAdminResetAccountPassword, srv.adminResetAccountPassword)
mux.HandleFunc("GET /ws", srv.websocket)
mux.HandleFunc("GET /api/room/events", srv.roomEvents)
mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia)
@@ -148,10 +180,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
@@ -174,13 +210,13 @@ 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) {
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, s.TrustedProxies)}
switch auth {
case protocol.AuthNone:
case protocol.AuthMember:
@@ -196,7 +232,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 +243,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)
}
@@ -276,6 +312,26 @@ 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},
{playlistorder.ErrBeforeMoved, protocol.ErrorCodeInvalidArgument},
{playlistorder.ErrBeforeMissing, protocol.ErrorCodeConflict},
{realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument},
{site.ErrInvalidBrandName, protocol.ErrorCodeInvalidArgument},
{realtime.ErrUnavailable, protocol.ErrorCodeUnavailable},
@@ -294,7 +350,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) {
+55 -1
View File
@@ -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"
@@ -42,6 +43,7 @@ func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string
l := lobby.New(rooms, node)
return Services{
Identity: identity.New(store, rooms, store, gateway),
Accounts: identity.NewAccounts(store, store, nil, gateway, slog.New(slog.DiscardHandler)),
Lobby: l,
Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
Gateway: gateway,
@@ -153,6 +155,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)
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 +352,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("连接中断")
}
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"net/http"
"net/netip"
"slices"
"strings"
)
// 只有对端是受信任的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理,
// 第一个不受信任的地址就是客户端;全部受信任时取最左端的地址。受信任的代理只会写入合法的地址,
// 遇到不合法的值说明已经到了客户端自己填写的部分,此时改用对端地址。
func clientIP(r *http.Request, trusted []netip.Prefix) string {
peer, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
addr := peer.Addr().Unmap()
if !containsAddr(trusted, 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 || !containsAddr(trusted, hop) {
return hop.String()
}
}
return addr.String()
}
func containsAddr(prefixes []netip.Prefix, addr netip.Addr) bool {
return slices.ContainsFunc(prefixes, func(p netip.Prefix) bool { return p.Contains(addr) })
}
+47
View File
@@ -0,0 +1,47 @@
package api
import (
"net/http/httptest"
"net/netip"
"testing"
)
func TestClientIP(t *testing.T) {
trusted := []netip.Prefix{netip.MustParsePrefix("10.42.0.0/16"), netip.MustParsePrefix("127.0.0.1/32")}
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, 10.42.0.1"}, "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{"10.42.0.1"}, "10.42.0.1"},
{"没有 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"},
}
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, trusted); got != tc.want {
t.Errorf("clientIP = %q,期望 %q", got, tc.want)
}
})
}
t.Run("没有配置受信任的代理", func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = "10.42.0.153:4321"
r.Header.Set("X-Forwarded-For", "203.0.113.7")
if got := clientIP(r, nil); got != "10.42.0.153" {
t.Errorf("clientIP = %q,期望对端地址", got)
}
})
}
+25 -9
View File
@@ -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,38 @@ 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
}
// 封面在对象存储中的曲目,对外的封面地址由公开地址拼出。
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 (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),
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
+3 -3
View File
@@ -23,7 +23,7 @@ const (
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 看到它就该停下,而不是重连之后互相顶掉。
@@ -41,7 +41,7 @@ type streamClose struct {
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)
@@ -83,7 +83,7 @@ func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
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(credential[:]))
if err != nil {
return nil, err
}
+1 -1
View File
@@ -145,7 +145,7 @@ 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)
})
}
+16 -6
View File
@@ -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,17 @@ 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
}
return resp, nil
}
func (s *server) updateMe(ctx context.Context, c call) (any, error) {
@@ -88,7 +98,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
}
+146
View File
@@ -0,0 +1,146 @@
package api
import (
"context"
"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,
}
}
func (s *server) playlistItemDTO(item port.PlaylistItem) protocol.MyPlaylistItem {
return protocol.MyPlaylistItem{
Track: protocol.Track{Source: protocol.Source(item.Track.Source), ID: item.Track.ID},
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)
}
+2 -2
View File
@@ -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)
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)
if err != nil {
t.Fatal(err)
}
+13 -2
View File
@@ -7,11 +7,11 @@ 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
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen}, nil
}
func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
@@ -25,3 +25,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)
}
+6 -3
View File
@@ -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 + `","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 {
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -41,7 +41,7 @@ func (s *Store) ResetRoomAPIKey(ctx context.Context, roomID string, k port.NewAP
return err
}
} else {
m, err := q.CreateMember(ctx, query.CreateMemberParams{TokenHash: k.BotTokenHash, Name: botName})
m, err := q.CreateMember(ctx, botName)
if err != nil {
return err
}
+17 -3
View File
@@ -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 {
@@ -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)
}
}
@@ -9,31 +9,144 @@ 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 accountByUsername = `-- name: AccountByUsername :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email FROM members WHERE username = $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) AccountByUsername(ctx context.Context, username *string) (Member, error) {
row := q.db.QueryRow(ctx, accountByUsername, username)
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
)
return i, err
}
const createMember = `-- name: CreateMember :one
INSERT INTO members (name) VALUES ($1)
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
`
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,
)
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 SHARE
`
// 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 FROM members WHERE id = $1
`
type MemberAccountProfileRow struct {
Username *string
Email *string
}
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)
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 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 +154,102 @@ 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,
)
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
`
type RegisterMemberParams struct {
Username *string
PasswordHash *string
ID string
}
// 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
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 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 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
`
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,
)
return i, err
}
@@ -9,11 +9,28 @@ 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
}
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,487 @@
// 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 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
}
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, 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
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.TrackCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
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 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,17 +10,25 @@ 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
}
@@ -37,7 +45,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 +53,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 +70,7 @@ func (q *Queries) TicketHolder(ctx context.Context, tokenHash []byte) (TicketHol
&i.Name,
&i.AvatarHash,
&i.Bot,
&i.SessionTokenHash,
)
return i, err
}
+149 -4
View File
@@ -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,138 @@ 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)}, 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 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 port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}, 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
}
n, err := q.DeleteSession(ctx, oldTokenHash)
if err != nil {
return err
}
if n == 0 {
return port.ErrSessionNotFound
}
return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: 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})
}
+331 -5
View File
@@ -5,6 +5,8 @@ import (
"errors"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
@@ -59,13 +61,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 +80,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 +98,331 @@ 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)
}
}
@@ -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 {
@@ -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)
}
}
+315
View File
@@ -0,0 +1,315 @@
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],
}
}
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) error {
q := s.q.WithTx(tx)
if err := q.LockFavorites(ctx, ownerID); err != nil {
return err
}
n, err := q.CountPlaylists(ctx, ownerID)
if err != nil {
return err
}
if n >= int64(limit) {
return port.ErrPlaylistLimit
}
created, err = q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
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) 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)
}
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, nil
}
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 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,603 @@
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, _ := 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)
}
}
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("移动之后更新时间没有推进")
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ func addTicket(t *testing.T, pool *pgxpool.Pool, roomID string) {
t.Helper()
_, err := pool.Exec(t.Context(), `
WITH m AS (
INSERT INTO members (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`,
+13 -6
View File
@@ -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
}
+23 -9
View File
@@ -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)
+17 -5
View File
@@ -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"
@@ -27,10 +28,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 +45,19 @@ 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
}
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
}
+86 -3
View File
@@ -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,87 @@ func TestTicketHolder(t *testing.T) {
}
}
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
View File
@@ -198,6 +198,15 @@ func (b *Bus) PublishMember(ctx context.Context, m member.Member) error {
return err
}
func (b *Bus) PublishSessionsRevoked(ctx context.Context, sessionTokenHashes [][]byte) error {
data, err := json.Marshal(sessionTokenHashes)
if err != nil {
return err
}
_, err = b.publish(ctx, membersChannel, kindSessionsRevoked, data, nil)
return err
}
func (b *Bus) PublishPrepared(ctx context.Context, o port.PrepareOutcome) error {
data, err := json.Marshal(o)
if err != nil {
@@ -362,6 +371,10 @@ func (b *Bus) delivery(channel string, e envelope) (port.Delivery, error) {
var m member.Member
err := json.Unmarshal(e.Data, &m)
return port.MemberDelivery{Member: m}, err
case channel == membersChannel && e.Kind == kindSessionsRevoked:
var hashes [][]byte
err := json.Unmarshal(e.Data, &hashes)
return port.SessionsRevokedDelivery{SessionTokenHashes: hashes}, err
case channel == preparedChannel && e.Kind == kindPrepared:
var o port.PrepareOutcome
err := json.Unmarshal(e.Data, &o)
+19
View File
@@ -344,6 +344,25 @@ func TestPreparedOutcomes(t *testing.T) {
}
}
func TestSessionsRevoked(t *testing.T) {
client := newClient(t)
a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
for _, f := range []*busFixture{a, b} {
if err := f.bus.SubscribeInstance(t.Context()); err != nil {
t.Fatal(err)
}
}
hashes := [][]byte{{0x01, 0x02}, {0xff, 0x00, 0x7f}}
if err := a.bus.PublishSessionsRevoked(t.Context(), hashes); err != nil {
t.Fatal(err)
}
for _, f := range []*busFixture{a, b} {
if got := f.next(t); !reflect.DeepEqual(got, port.SessionsRevokedDelivery{SessionTokenHashes: hashes}) {
t.Errorf("收到 %#v,期望被删除的会话 %x", got, hashes)
}
}
}
func TestUnsubscribeRoom(t *testing.T) {
client := newClient(t)
a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
+6 -5
View File
@@ -55,11 +55,12 @@ type (
)
const (
kindEmitted = "Emitted"
kindReplied = "Replied"
kindInput = "RoomInput"
kindMember = "Member"
kindPrepared = "Prepared"
kindEmitted = "Emitted"
kindReplied = "Replied"
kindInput = "RoomInput"
kindMember = "Member"
kindSessionsRevoked = "SessionsRevoked"
kindPrepared = "Prepared"
// room.Command 的 Action 是接口,不能像 inputs 中的类型那样直接编码。
inputCommand = "Command"
)
+27 -1
View File
@@ -31,10 +31,36 @@ func (q *RoomQuota) TakePick(ctx context.Context, roomID string, burst int, refi
}
func (q *RoomQuota) take(ctx context.Context, roomID, bucket, what string, burst int, refill time.Duration) (time.Duration, error) {
res, err := q.limiter.Allow(ctx, "room:"+roomID+":"+bucket, redis_rate.Limit{Rate: 1, Burst: burst, Period: refill})
wait, err := take(ctx, q.limiter, "room:"+roomID+":"+bucket, burst, refill)
if err != nil {
return 0, fmt.Errorf("取房间 %s 的%s额度:%w", roomID, what, err)
}
return wait, nil
}
var _ port.AccountQuota = (*AccountQuota)(nil)
type AccountQuota struct {
limiter *redis_rate.Limiter
}
func NewAccountQuota(client *goredis.Client) *AccountQuota {
return &AccountQuota{limiter: redis_rate.NewLimiter(client)}
}
func (q *AccountQuota) Take(ctx context.Context, key string, burst int, refill time.Duration) (time.Duration, error) {
wait, err := take(ctx, q.limiter, "account:"+key, burst, refill)
if err != nil {
return 0, fmt.Errorf("取 %s 的额度:%w", key, err)
}
return wait, nil
}
func take(ctx context.Context, limiter *redis_rate.Limiter, key string, burst int, refill time.Duration) (time.Duration, error) {
res, err := limiter.Allow(ctx, key, redis_rate.Limit{Rate: 1, Burst: burst, Period: refill})
if err != nil {
return 0, err
}
if res.Allowed > 0 {
return 0, nil
}
+23
View File
@@ -43,3 +43,26 @@ func TestTakePick(t *testing.T) {
t.Errorf("另一个房间 = %v, %v,点歌额度按房间计算", wait, err)
}
}
func TestAccountQuota(t *testing.T) {
client := newClient(t)
quota := NewAccountQuota(client)
for i := range 2 {
if wait, err := quota.Take(t.Context(), "login:ip:203.0.113.7", 2, time.Minute); err != nil || wait != 0 {
t.Fatalf("第 %d 次 = %v, %v,突发额度之内应直接取到", i+1, wait, err)
}
}
wait, err := quota.Take(t.Context(), "login:ip:203.0.113.7", 2, time.Minute)
if err != nil {
t.Fatal(err)
}
if wait < 59*time.Second || wait > time.Minute {
t.Errorf("额度用完后要等 %v,期望约 1 分钟", wait)
}
if wait, err := quota.Take(t.Context(), "login:user:alice", 2, time.Minute); err != nil || wait != 0 {
t.Errorf("另一个键 = %v, %v,额度按键计算", wait, err)
}
if wait, err := NewRoomQuota(client).TakeSearch(t.Context(), "login:ip:203.0.113.7", 1, time.Minute); err != nil || wait != 0 {
t.Errorf("房间额度 = %v, %v,账号额度的键不能与房间额度的键重合", wait, err)
}
}
+4 -3
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"errors"
"log/slog"
"reflect"
"strconv"
"strings"
"testing"
@@ -149,7 +150,7 @@ func (f fixture) enter(t *testing.T, roomID, name string) ([]byte, *realtime.Ses
if err := f.store.CreateTicket(t.Context(), port.NewTicket{TokenHash: hash[:], RoomID: roomID, MemberID: m.ID, PasswordHash: r.PasswordHash}); err != nil {
t.Fatal(err)
}
session, err := f.gateway.Join(t.Context(), roomID, m)
session, err := f.gateway.Join(t.Context(), roomID, m, nil)
if err != nil {
t.Fatal(err)
}
@@ -322,7 +323,7 @@ func TestRoomAPIKey(t *testing.T) {
if _, err := holder(key); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("重置之后旧 Key 的错误 = %v,期望 ErrTicketNotFound", err)
}
if got, err := holder(reset); err != nil || got != bot {
if got, err := holder(reset); err != nil || !reflect.DeepEqual(got, bot) {
t.Errorf("重置之后新 Key 的持有者 = %+v, %v,期望 bot 成员不变", got, err)
}
@@ -331,7 +332,7 @@ func TestRoomAPIKey(t *testing.T) {
if err := f.admin.UpdateRoom(t.Context(), id, RoomChange{Password: ptr(password)}); err != nil {
t.Fatal(err)
}
if got, err := holder(reset); err != nil || got != bot {
if got, err := holder(reset); err != nil || !reflect.DeepEqual(got, bot) {
t.Errorf("密码改为 %q 之后 Key 的持有者 = %+v, %v,期望 Key 仍然有效", password, got, err)
}
}
+247
View File
@@ -0,0 +1,247 @@
package identity
import (
"context"
"errors"
"log/slog"
"net/netip"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/internal/domain/member"
)
var (
ErrRegistrationClosed = errors.New("注册已关闭")
ErrAlreadyLoggedIn = errors.New("已经登录了账号,先退出再登录其他账号")
ErrInvalidCredentials = errors.New("用户名或密码错误")
ErrWrongCurrentPassword = errors.New("当前密码错误")
ErrNotRegistered = errors.New("还没有注册账号")
ErrTooManyAttempts = errors.New("尝试次数太多,请稍后再试。")
ErrAccountNotFound = errors.New("没有这个用户名的账号")
)
// 注册、登录和修改密码都要做一次 argon2id 计算:额度既防止猜密码,也防止耗尽计算槽位。
const (
registerBurst = 5
registerRefill = 10 * time.Minute
loginIPBurst = 10
loginIPRefill = time.Minute
loginUserBurst = 5
loginUserRefill = time.Minute
passwordBurst = 5
passwordRefill = time.Minute
)
type Account struct {
Username string
Email string
}
// 删除会话之后,用这些会话的票据建立的房间连接立即断开;数据已经修改而断开的通知没有发出时返回错误,
// 只有撤销发起请求的这台设备自己的会话(Login、Logout)例外,只记日志。
type Accounts struct {
members port.Members
site port.Site
quota port.AccountQuota
gateway *realtime.Gateway
logger *slog.Logger
}
func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, gateway *realtime.Gateway, logger *slog.Logger) *Accounts {
return &Accounts{members: members, site: site, quota: quota, gateway: gateway, logger: logger}
}
// 游客没有账号,ok 为 false。
func (a *Accounts) Account(ctx context.Context, memberID string) (account Account, ok bool, err error) {
profile, err := a.members.AccountProfile(ctx, memberID)
if err != nil {
return Account{}, false, err
}
return Account{Username: profile.Username, Email: profile.Email}, profile.Username != "", nil
}
// 给游客设置用户名和密码,成员和它的会话都不变。username 和 password 已经按协议校验过格式。
func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, password string) (Account, error) {
settings, err := a.site.Settings(ctx)
if err != nil {
return Account{}, err
}
if !settings.RegistrationOpen {
return Account{}, ErrRegistrationClosed
}
if err := a.take(ctx, "register:ip:"+quotaAddr(clientIP), registerBurst, registerRefill); err != nil {
return Account{}, err
}
hash, err := HashPassword(ctx, password)
if err != nil {
return Account{}, err
}
if err := a.members.Register(ctx, memberID, username, hash); err != nil {
return Account{}, err
}
return Account{Username: username}, nil
}
// current 是发出请求的成员,currentToken 是它的成员 token。只有游客能登录:成功后签发账号的新会话,游客的这个会话随即失效。
func (a *Accounts) Login(ctx context.Context, current member.Member, currentToken, clientIP, username, password string) (Session, Account, error) {
if _, registered, err := a.Account(ctx, current.ID); err != nil {
return Session{}, Account{}, err
} else if registered {
return Session{}, Account{}, ErrAlreadyLoggedIn
}
// 按 IP 限制一个来源的尝试次数,按用户名限制一个账号被尝试的次数:换着 IP 猜同一个账号也会受限。
if err := a.take(ctx, "login:ip:"+quotaAddr(clientIP), loginIPBurst, loginIPRefill); err != nil {
return Session{}, Account{}, err
}
if err := a.take(ctx, "login:user:"+username, loginUserBurst, loginUserRefill); err != nil {
return Session{}, Account{}, err
}
account, err := a.verify(ctx, username, password)
if err != nil {
return Session{}, Account{}, err
}
token, hash := newSecret()
guest := hashSecret(currentToken)
err = a.members.ReplaceSession(ctx, guest, account.Member.ID, hash, account.PasswordHash)
switch {
case errors.Is(err, port.ErrSessionNotFound):
return Session{}, Account{}, ErrInvalidMemberToken
case errors.Is(err, port.ErrAccountPasswordChanged):
return Session{}, Account{}, ErrInvalidCredentials
case err != nil:
return Session{}, Account{}, err
}
// 页面随后会重新加载,游客的连接即使没断开也不再使用;此时返回错误会让用户拿不到已经签发的新会话。
if err := a.gateway.RevokeSessions(ctx, [][]byte{guest}); err != nil {
a.logger.WarnContext(ctx, "登录后断开游客会话的通知没有发出", "error", err)
}
return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username, Email: account.Email}, nil
}
func (a *Accounts) verify(ctx context.Context, username, password string) (port.Account, error) {
account, err := a.members.AccountByUsername(ctx, username)
if errors.Is(err, port.ErrMemberNotFound) {
dummy, err := dummyPasswordHash()
if err != nil {
return port.Account{}, err
}
if _, err := checkPassword(ctx, dummy, password); err != nil {
return port.Account{}, err
}
return port.Account{}, ErrInvalidCredentials
}
if err != nil {
return port.Account{}, err
}
ok, err := checkPassword(ctx, account.PasswordHash, password)
if err != nil {
return port.Account{}, err
}
if !ok {
return port.Account{}, ErrInvalidCredentials
}
return account, nil
}
func (a *Accounts) Logout(ctx context.Context, token string) error {
hash := hashSecret(token)
if err := a.members.DeleteSession(ctx, hash); err != nil {
return err
}
// 页面随后会重新加载,不因通知没有发出而让退出失败。
if err := a.gateway.RevokeSessions(ctx, [][]byte{hash}); err != nil {
a.logger.WarnContext(ctx, "退出后断开会话的通知没有发出", "error", err)
}
return nil
}
// 修改成功后,账号除了发出请求的这个会话,其他会话都失效。
func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentPassword, newPassword string) error {
profile, err := a.members.AccountProfile(ctx, memberID)
if err != nil {
return err
}
username := profile.Username
if username == "" {
return ErrNotRegistered
}
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
return err
}
account, err := a.verify(ctx, username, currentPassword)
if errors.Is(err, ErrInvalidCredentials) {
return ErrWrongCurrentPassword
} else if err != nil {
return err
}
hash, err := HashPassword(ctx, newPassword)
if err != nil {
return err
}
// 校验之后密码可能被重置或被别的设备改过:发出请求的会话已经被删掉时按 token 失效处理,同一个会话先改过时当前密码已经不对。
deleted, err := a.members.ChangePassword(ctx, memberID, account.PasswordHash, hash, hashSecret(token))
switch {
case errors.Is(err, port.ErrSessionNotFound):
return ErrInvalidMemberToken
case errors.Is(err, port.ErrAccountPasswordChanged):
return ErrWrongCurrentPassword
case err != nil:
return err
}
return a.gateway.RevokeSessions(ctx, deleted)
}
// Find 和 ResetPassword 只供后台使用,调用方要先校验管理 token。
func (a *Accounts) Find(ctx context.Context, username string) (port.Account, error) {
account, err := a.members.AccountByUsername(ctx, username)
if errors.Is(err, port.ErrMemberNotFound) {
return port.Account{}, ErrAccountNotFound
}
return account, err
}
// 生成随机的新密码,账号在所有设备上的会话随即失效。新密码只出现在返回值中。
func (a *Accounts) ResetPassword(ctx context.Context, username string) (string, error) {
account, err := a.Find(ctx, username)
if err != nil {
return "", err
}
password := newPassword()
hash, err := HashPassword(ctx, password)
if err != nil {
return "", err
}
deleted, err := a.members.SetPassword(ctx, account.Member.ID, hash)
if err != nil {
return "", err
}
if err := a.gateway.RevokeSessions(ctx, deleted); err != nil {
return "", err
}
return password, nil
}
// IPv6 客户端通常分到整个 /64,可以在里面随意换地址,所以按 /64 计数。clientIP 由适配器给出,解析不了时原样使用。
func quotaAddr(clientIP string) string {
addr, err := netip.ParseAddr(clientIP)
if err != nil {
return clientIP
}
if addr = addr.Unmap(); addr.Is4() {
return addr.String()
}
return netip.PrefixFrom(addr, 64).Masked().String()
}
func (a *Accounts) take(ctx context.Context, key string, burst int, refill time.Duration) error {
wait, err := a.quota.Take(ctx, key, burst, refill)
if err != nil {
return err
}
if wait > 0 {
return ErrTooManyAttempts
}
return nil
}
+593
View File
@@ -0,0 +1,593 @@
package identity
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"regexp"
"slices"
"sync"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/internal/domain/member"
)
const clientIP = "203.0.113.7"
type fakeQuota struct {
mu sync.Mutex
taken []string
exhausted map[string]bool
}
func (q *fakeQuota) Take(_ context.Context, key string, burst int, refill time.Duration) (time.Duration, error) {
q.mu.Lock()
defer q.mu.Unlock()
if burst <= 0 || refill <= 0 {
return 0, fmt.Errorf("额度参数不合理:burst=%d refill=%v", burst, refill)
}
q.taken = append(q.taken, key)
if q.exhausted[key] {
return time.Minute, nil
}
return 0, nil
}
// revoked 返回此前广播过的被删除的会话。
func newAccounts(t *testing.T) (a *Accounts, s *Service, store *porttest.Store, q *fakeQuota, revoked func() [][]byte) {
t.Helper()
store = porttest.New()
node := porttest.NewRedis().Node("a")
if err := node.SubscribeInstance(t.Context()); err != nil {
t.Fatal(err)
}
gateway := newGateway(store, node, node)
q = &fakeQuota{exhausted: map[string]bool{}}
revoked = func() [][]byte {
t.Helper()
done, cancel := context.WithCancel(t.Context())
cancel()
var hashes [][]byte
for {
d, err := node.Receive(done)
if err != nil {
return hashes
}
if r, ok := d.(port.SessionsRevokedDelivery); ok {
hashes = append(hashes, r.SessionTokenHashes...)
}
}
}
return NewAccounts(store, store, q, gateway, slog.New(slog.DiscardHandler)), New(store, store, store, gateway), store, q, revoked
}
// 不计顺序。
func sameHashes(got [][]byte, tokens ...string) bool {
want := make([]string, len(tokens))
for i, token := range tokens {
want[i] = string(hashSecret(token))
}
keys := make([]string, len(got))
for i, h := range got {
keys[i] = string(h)
}
slices.Sort(want)
slices.Sort(keys)
return slices.Equal(keys, want)
}
func issueTicket(t *testing.T, s *Service, session Session, roomID string) string {
t.Helper()
ticket, err := s.IssueTicket(t.Context(), session.Member.ID, session.MemberToken, roomID, "")
if err != nil {
t.Fatal(err)
}
return ticket
}
func ticketValid(t *testing.T, s *Service, ticket string) bool {
t.Helper()
_, err := s.VerifyTicket(t.Context(), ticket)
if err != nil && !errors.Is(err, ErrInvalidTicket) {
t.Fatal(err)
}
return err == nil
}
// 返回 token 当前所属的成员;token 已经失效时 ok 为 false。
func tokenOwner(t *testing.T, store *porttest.Store, token string) (m member.Member, ok bool) {
t.Helper()
m, err := store.MemberByToken(t.Context(), hashSecret(token))
if errors.Is(err, port.ErrMemberNotFound) {
return member.Member{}, false
}
if err != nil {
t.Fatal(err)
}
return m, true
}
func registerAccount(t *testing.T, a *Accounts, s *Service, username, password string) Session {
t.Helper()
session := createMember(t, s)
if _, err := a.Register(t.Context(), session.Member.ID, clientIP, username, password); err != nil {
t.Fatal(err)
}
return session
}
func TestRegister(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
guest := createMember(t, s)
account, err := a.Register(t.Context(), guest.Member.ID, clientIP, "alice", "correct horse")
if err != nil || account != (Account{Username: "alice"}) {
t.Fatalf("Register = %#v, %v", account, err)
}
if got, ok, err := a.Account(t.Context(), guest.Member.ID); err != nil || !ok || got != account {
t.Errorf("注册之后 Account = %#v, %v, %v", got, ok, err)
}
if m, ok := tokenOwner(t, store, guest.MemberToken); !ok || m != guest.Member {
t.Error("注册不更换会话:原来的成员 token 应仍然有效")
}
stored, err := store.AccountByUsername(t.Context(), "alice")
if err != nil {
t.Fatal(err)
}
if ok, err := checkPassword(t.Context(), stored.PasswordHash, "correct horse"); err != nil || !ok {
t.Errorf("存储中应是密码的 argon2id 哈希:%v, %v", ok, err)
}
if want := []string{"register:ip:" + clientIP}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
}
func TestAccountKeepsEmail(t *testing.T) {
a, s, _, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
if _, err := s.UpdateProfile(t.Context(), alice.Member.ID, Profile{Email: ptr(" Alice@Example.COM ")}); err != nil {
t.Fatal(err)
}
if got, ok, err := a.Account(t.Context(), alice.Member.ID); err != nil || !ok || got != (Account{Username: "alice", Email: "Alice@Example.COM"}) {
t.Errorf("保存邮箱之后 Account = %#v, %v, %v", got, ok, err)
}
other := createMember(t, s)
_, account, err := a.Login(t.Context(), other.Member, other.MemberToken, clientIP, "alice", "correct horse")
if err != nil || account != (Account{Username: "alice", Email: "Alice@Example.COM"}) {
t.Errorf("另一台设备登录得到 %#v, %v,期望带邮箱", account, err)
}
if _, err := s.UpdateProfile(t.Context(), alice.Member.ID, Profile{Email: ptr(" ")}); err != nil {
t.Fatal(err)
}
if got, _, _ := a.Account(t.Context(), alice.Member.ID); got.Email != "" {
t.Errorf("清除之后邮箱 = %q", got.Email)
}
if _, err := s.UpdateProfile(t.Context(), guest.Member.ID, Profile{Email: ptr("g@example.com")}); err != nil {
t.Fatal(err)
}
if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok {
t.Error("游客设置邮箱之后仍然没有账号")
}
}
func TestGuestHasNoAccount(t *testing.T) {
a, s, _, _, _ := newAccounts(t)
if _, ok, err := a.Account(t.Context(), createMember(t, s).Member.ID); err != nil || ok {
t.Errorf("游客的 Account = %v, %v,期望没有账号", ok, err)
}
}
func TestRegisterRejected(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
if _, err := a.Register(t.Context(), createMember(t, s).Member.ID, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
if _, err := a.Register(t.Context(), alice.Member.ID, clientIP, "alice2", "another horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("已经注册过时 = %v,期望 ErrAlreadyRegistered", err)
}
guest := createMember(t, s)
q.exhausted["register:ip:"+clientIP] = true
if _, err := a.Register(t.Context(), guest.Member.ID, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时 = %v,期望 ErrTooManyAttempts", err)
}
if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok {
t.Error("额度用完时不应注册")
}
if err := store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
q.taken = nil
if _, err := a.Register(t.Context(), guest.Member.ID, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
t.Errorf("关闭注册时 = %v,期望 ErrRegistrationClosed", err)
}
if len(q.taken) != 0 {
t.Errorf("关闭注册时不应消耗额度:%v", q.taken)
}
}
func TestLogin(t *testing.T) {
a, s, store, q, revoked := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
id := createRoom(t, store, "")
guestTicket, aliceTicket := issueTicket(t, s, guest, id), issueTicket(t, s, alice, id)
q.taken = nil
session, account, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse")
if err != nil {
t.Fatal(err)
}
if got := revoked(); !sameHashes(got, guest.MemberToken) {
t.Errorf("广播的被删除的会话 = %x,期望游客原来的会话", got)
}
if ticketValid(t, s, guestTicket) || !ticketValid(t, s, aliceTicket) {
t.Error("登录之后只有游客原来的会话的票据失效")
}
if session.Member != alice.Member || account != (Account{Username: "alice"}) {
t.Errorf("Login = %#v, %#v,期望登录 alice", session.Member, account)
}
if session.MemberToken == alice.MemberToken || session.MemberToken == guest.MemberToken {
t.Error("登录应签发新的成员 token")
}
if m, ok := tokenOwner(t, store, session.MemberToken); !ok || m != alice.Member {
t.Errorf("新 token 属于 %#v,期望 alice", m)
}
if _, ok := tokenOwner(t, store, guest.MemberToken); ok {
t.Error("登录之后游客原来的 token 应失效")
}
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
t.Error("登录不影响账号在其他设备上的会话")
}
if want := []string{"login:ip:" + clientIP, "login:user:alice"}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
}
func TestLoginRejected(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
for _, tc := range []struct{ name, username, password string }{
{"密码错误", "alice", "wrong horse"},
{"没有这个用户名", "nobody", "correct horse"},
} {
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, tc.username, tc.password); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("%s:Login = %v,期望 ErrInvalidCredentials", tc.name, err)
}
}
if _, ok := tokenOwner(t, store, guest.MemberToken); !ok {
t.Error("登录失败时游客的 token 应仍然有效")
}
if _, _, err := a.Login(t.Context(), alice.Member, alice.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrAlreadyLoggedIn) {
t.Errorf("已注册的成员登录 = %v,期望 ErrAlreadyLoggedIn", err)
}
q.exhausted["login:user:alice"] = true
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, "198.51.100.1", "alice", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("用户名的额度用完时换一个 IP = %v,期望 ErrTooManyAttempts", err)
}
q.exhausted["login:ip:"+clientIP] = true
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("IP 的额度用完时换一个用户名 = %v,期望 ErrTooManyAttempts", err)
}
}
func TestAccountQuotaCountsIPv6ByNetwork(t *testing.T) {
a, s, _, q, _ := newAccounts(t)
guest := createMember(t, s)
q.exhausted["login:ip:2001:db8:1:2::/64"] = true
for _, tc := range []struct {
ip, key string
want error
}{
{"2001:db8:1:2::1", "login:ip:2001:db8:1:2::/64", ErrTooManyAttempts},
{"2001:db8:1:2:aaaa:bbbb:cccc:dddd", "login:ip:2001:db8:1:2::/64", ErrTooManyAttempts},
{"2001:db8:1:3::1", "login:ip:2001:db8:1:3::/64", ErrInvalidCredentials},
{"203.0.113.7", "login:ip:203.0.113.7", ErrInvalidCredentials},
{"::ffff:203.0.113.7", "login:ip:203.0.113.7", ErrInvalidCredentials},
} {
q.taken = nil
_, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, tc.ip, "nobody", "wrong horse")
if !errors.Is(err, tc.want) {
t.Errorf("从 %s 登录 = %v,期望 %v", tc.ip, err, tc.want)
}
if len(q.taken) == 0 || q.taken[0] != tc.key {
t.Errorf("从 %s 登录取的额度 = %v,期望先取 %s", tc.ip, q.taken, tc.key)
}
}
q.taken = nil
if _, err := a.Register(t.Context(), guest.Member.ID, "2001:db8:1:2:aaaa:bbbb:cccc:dddd", "alice", "correct horse"); err != nil {
t.Fatal(err)
}
if want := []string{"register:ip:2001:db8:1:2::/64"}; !slices.Equal(q.taken, want) {
t.Errorf("从 IPv6 地址注册取的额度 = %v,期望 %v", q.taken, want)
}
}
func TestLogout(t *testing.T) {
a, s, store, _, revoked := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
id := createRoom(t, store, "")
aliceTicket := issueTicket(t, s, alice, id)
revoked()
if err := a.Logout(t.Context(), alice.MemberToken); err != nil {
t.Fatal(err)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); ok {
t.Error("退出登录之后 token 应失效")
}
if got := revoked(); !sameHashes(got, alice.MemberToken) {
t.Errorf("退出登录之后广播的会话 = %x,期望当前会话", got)
}
if ticketValid(t, s, aliceTicket) {
t.Error("退出登录之后这个会话的票据应失效")
}
}
func TestChangePassword(t *testing.T) {
a, s, store, q, revoked := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
other, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse")
if err != nil {
t.Fatal(err)
}
id := createRoom(t, store, "")
aliceTicket, otherTicket := issueTicket(t, s, alice, id), issueTicket(t, s, other, id)
revoked()
q.taken = nil
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "wrong horse", "battery staple"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("当前密码错误时 = %v,期望 ErrWrongCurrentPassword", err)
}
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); err != nil {
t.Fatal(err)
}
if want := []string{"password:member:" + alice.Member.ID, "password:member:" + alice.Member.ID}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
t.Error("修改密码不影响发出请求的会话")
}
if _, ok := tokenOwner(t, store, other.MemberToken); ok {
t.Error("修改密码之后其他会话应失效")
}
if got := revoked(); !sameHashes(got, other.MemberToken) {
t.Errorf("修改密码之后广播的会话 = %x,期望其他设备的会话", got)
}
if !ticketValid(t, s, aliceTicket) || ticketValid(t, s, otherTicket) {
t.Error("修改密码之后只有其他设备的票据失效")
}
next := createMember(t, s)
if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("用旧密码登录 = %v,期望 ErrInvalidCredentials", err)
}
if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "battery staple"); err != nil {
t.Errorf("用新密码登录 = %v", err)
}
stranger := createMember(t, s)
if err := a.ChangePassword(t.Context(), stranger.Member.ID, stranger.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客修改密码 = %v,期望 ErrNotRegistered", err)
}
}
func TestResetPassword(t *testing.T) {
a, s, store, _, revoked := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
aliceTicket := issueTicket(t, s, alice, createRoom(t, store, ""))
if _, err := a.Find(t.Context(), "nobody"); !errors.Is(err, ErrAccountNotFound) {
t.Errorf("Find 不存在的用户名 = %v,期望 ErrAccountNotFound", err)
}
if _, err := a.ResetPassword(t.Context(), "nobody"); !errors.Is(err, ErrAccountNotFound) {
t.Errorf("ResetPassword 不存在的用户名 = %v,期望 ErrAccountNotFound", err)
}
if found, err := a.Find(t.Context(), "alice"); err != nil || found.Member != alice.Member || found.Username != "alice" {
t.Errorf("Find = %#v, %v", found, err)
}
password, err := a.ResetPassword(t.Context(), "alice")
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^[A-Za-z0-9_-]{16}$`).MatchString(password) {
t.Errorf("新密码 %q 应是 16 个 base64url 字符", password)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); ok {
t.Error("重置密码之后账号的所有会话应失效")
}
if got := revoked(); !sameHashes(got, alice.MemberToken) {
t.Errorf("重置密码之后广播的会话 = %x,期望账号的所有会话", got)
}
if ticketValid(t, s, aliceTicket) {
t.Error("重置密码之后账号的票据应失效")
}
guest := createMember(t, s)
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", password); err != nil {
t.Errorf("用重置后的密码登录 = %v", err)
}
}
type noRevocation struct {
port.Bus
}
func (noRevocation) PublishSessionsRevoked(context.Context, [][]byte) error {
return porttest.ErrRedisDown
}
// 撤销的是别的设备的会话:断开的通知没有发出时报错,让用户知道它们没有被踢出去。
func TestOtherDeviceOperationsFailWhenRevocationFails(t *testing.T) {
store := porttest.New()
node := porttest.NewRedis().Node("a")
gateway := newGateway(store, node, noRevocation{node})
s := New(store, store, store, gateway)
a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, gateway, slog.New(slog.DiscardHandler))
alice := registerAccount(t, a, s, "alice", "correct horse")
login := func() {
t.Helper()
guest := createMember(t, s)
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); err != nil {
t.Fatal(err)
}
}
// 每一步之前都先登录一次,让这一步有会话可删。
for _, step := range []struct {
name string
op func() error
}{
{"ChangePassword", func() error {
return a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse")
}},
{"ResetPassword", func() error {
_, err := a.ResetPassword(t.Context(), "alice")
return err
}},
} {
login()
if err := step.op(); !errors.Is(err, realtime.ErrUnavailable) {
t.Errorf("广播失败时 %s = %v,期望 realtime.ErrUnavailable", step.name, err)
}
}
}
// 登录和退出撤销的是发起请求的这台设备的会话,页面随后会重新加载:广播失败时只记日志,照常成功。
func TestLoginAndLogoutSucceedWhenRevocationFails(t *testing.T) {
store := porttest.New()
node := porttest.NewRedis().Node("a")
gateway := newGateway(store, node, noRevocation{node})
s := New(store, store, store, gateway)
var logs bytes.Buffer
a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, gateway, slog.New(slog.NewJSONHandler(&logs, nil)))
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
session, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse")
if err != nil {
t.Fatalf("广播失败时 Login = %v,期望成功", err)
}
if _, ok := tokenOwner(t, store, guest.MemberToken); ok {
t.Error("游客原来的会话应已经删除")
}
if m, ok := tokenOwner(t, store, session.MemberToken); !ok || m.ID != alice.Member.ID {
t.Error("新会话应已经签发")
}
if err := a.Logout(t.Context(), session.MemberToken); err != nil {
t.Fatalf("广播失败时 Logout = %v,期望成功", err)
}
if _, ok := tokenOwner(t, store, session.MemberToken); ok {
t.Error("退出的会话应已经删除")
}
if n := bytes.Count(logs.Bytes(), []byte(`"level":"WARN"`)); n != 2 {
t.Errorf("警告日志 %d 条,期望 Login 和 Logout 各 1 条:\n%s", n, logs.String())
}
}
// 在登录校验密码之后、签发新会话之前修改账号的密码。
type passwordChangedMembers struct {
*porttest.Store
}
func (s passwordChangedMembers) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
if _, err := s.SetPassword(ctx, memberID, "changed"); err != nil {
return err
}
return s.Store.ReplaceSession(ctx, oldTokenHash, memberID, newTokenHash, passwordHash)
}
func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
if err := store.DeleteSession(t.Context(), hashSecret(guest.MemberToken)); err != nil {
t.Fatal(err)
}
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidMemberToken) {
t.Errorf("游客的会话在校验之后失效,Login = %v,期望 ErrInvalidMemberToken", err)
}
node := porttest.NewRedis().Node("a")
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, newGateway(store, node, node), slog.New(slog.DiscardHandler))
guest = createMember(t, s)
if _, _, err := changing.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("密码在校验之后被修改,Login = %v,期望 ErrInvalidCredentials", err)
}
if _, ok := tokenOwner(t, store, guest.MemberToken); !ok {
t.Error("登录失败时游客的 token 应仍然有效")
}
}
// 校验当前密码读到旧的密码哈希之后,先执行 meanwhile 再继续。
type passwordReadMembers struct {
*porttest.Store
meanwhile func()
}
func (s passwordReadMembers) AccountByUsername(ctx context.Context, username string) (port.Account, error) {
account, err := s.Store.AccountByUsername(ctx, username)
s.meanwhile()
return account, err
}
func TestChangePasswordRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
node := porttest.NewRedis().Node("a")
racing := func(meanwhile func()) *Accounts {
return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}}, newGateway(store, node, node), slog.New(slog.DiscardHandler))
}
var reset string
resetting := racing(func() {
var err error
if reset, err = a.ResetPassword(t.Context(), "alice"); err != nil {
t.Fatal(err)
}
})
if err := resetting.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrInvalidMemberToken) {
t.Errorf("校验当前密码之后后台重置了密码,ChangePassword = %v,期望 ErrInvalidMemberToken", err)
}
guest := createMember(t, s)
session, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", reset)
if err != nil {
t.Fatalf("用重置出来的密码登录 = %v,期望成功", err)
}
changing := racing(func() {
if err := a.ChangePassword(t.Context(), session.Member.ID, session.MemberToken, reset, "first change"); err != nil {
t.Fatal(err)
}
})
if err := changing.ChangePassword(t.Context(), session.Member.ID, session.MemberToken, reset, "second change"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("校验当前密码之后同一个会话已经改了密码,ChangePassword = %v,期望 ErrWrongCurrentPassword", err)
}
next := createMember(t, s)
if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "first change"); err != nil {
t.Errorf("用先完成的那次修改的密码登录 = %v,期望成功", err)
}
}
// 名额占满、请求已经取消时,要做密码计算的请求以 context.Canceled 结束,不做的直接返回结果。
func TestLoginDoesPasswordWorkForUnknownUsername(t *testing.T) {
a, s, _, _, _ := newAccounts(t)
registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
if _, err := dummyPasswordHash(); err != nil {
t.Fatal(err)
}
occupyArgonSlots(t)
ctx, cancel := context.WithCancel(t.Context())
cancel()
for _, username := range []string{"alice", "nobody"} {
if _, _, err := a.Login(ctx, guest.Member, guest.MemberToken, clientIP, username, "wrong horse"); !errors.Is(err, context.Canceled) {
t.Errorf("用户名 %s:Login = %v,期望同样等待密码计算的名额", username, err)
}
}
}
+32 -11
View File
@@ -3,6 +3,8 @@ package identity
import (
"context"
"errors"
"regexp"
"strings"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/realtime"
@@ -15,8 +17,11 @@ var (
ErrInvalidTicket = errors.New("票据无效")
ErrWrongPassword = errors.New("房间密码错误")
ErrBotSession = errors.New("API Key 只能用于 HTTP 接口")
ErrInvalidEmail = errors.New("email:格式不正确,应形如 name@example.com")
)
var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
type Service struct {
members port.Members
rooms port.RoomDirectory
@@ -55,17 +60,21 @@ func (s *Service) Authenticate(ctx context.Context, memberToken string) (member.
type Profile struct {
Name *string
// 只用来计算头像哈希,不保存;空字符串表示清除头像。
// 用来计算头像哈希;已注册的账号同时保存它。空字符串表示清除。
Email *string
}
func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile) (member.Member, error) {
var avatar *string
var avatar, email *string
if p.Email != nil {
hash := avatarHash(*p.Email)
avatar = &hash
trimmed := strings.TrimSpace(*p.Email)
if trimmed != "" && !emailPattern.MatchString(trimmed) {
return member.Member{}, ErrInvalidEmail
}
hash := avatarHash(trimmed)
avatar, email = &hash, &trimmed
}
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar)
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar, email)
if err != nil {
return member.Member{}, err
}
@@ -75,7 +84,8 @@ func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile)
return m, nil
}
func (s *Service) IssueTicket(ctx context.Context, memberID, roomID, password string) (string, error) {
// memberToken 是发出请求的会话,票据属于这个会话:会话删除时票据随之失效。
func (s *Service) IssueTicket(ctx context.Context, memberID, memberToken, roomID, password string) (string, error) {
r, err := s.rooms.FindRoom(ctx, roomID)
if err != nil {
return "", err
@@ -90,11 +100,15 @@ func (s *Service) IssueTicket(ctx context.Context, memberID, roomID, password st
}
}
ticket, hash := newSecret()
err = s.tickets.CreateTicket(ctx, port.NewTicket{TokenHash: hash, RoomID: r.Info.ID, MemberID: memberID, PasswordHash: r.PasswordHash})
err = s.tickets.CreateTicket(ctx, port.NewTicket{TokenHash: hash, RoomID: r.Info.ID, MemberID: memberID, PasswordHash: r.PasswordHash, SessionTokenHash: hashSecret(memberToken)})
if errors.Is(err, port.ErrPasswordChanged) {
// 刚才是按旧密码校验的;客户端重新输入密码后,按新密码校验。
return "", ErrWrongPassword
}
if errors.Is(err, port.ErrSessionNotFound) {
// 会话在鉴权之后被删除(例如另一台设备退出登录或修改了密码),此时成员 token 确实已经失效。
return "", ErrInvalidMemberToken
}
if err != nil {
return "", err
}
@@ -128,12 +142,19 @@ func (s *Service) EnterRoom(ctx context.Context, roomID, ticket string) (*realti
if holder.Bot {
return nil, ErrBotSession
}
return s.gateway.Join(logctx.WithMember(logctx.WithRoom(ctx, r.Info.ID), holder.Member.ID), r.Info.ID, holder.Member)
session, err := s.gateway.Join(logctx.WithMember(logctx.WithRoom(ctx, r.Info.ID), holder.Member.ID), r.Info.ID, holder.Member, holder.SessionTokenHash)
if err != nil {
return nil, err
}
// 作废时先删除票据、再通知断开:会话加入网关之前发出的通知它收不到,所以加入之后再确认一次票据。
if _, err := s.VerifyTicket(ctx, ticket); err != nil {
s.gateway.Leave(session)
return nil, err
}
return session, nil
}
// 每次都准备好 bot 成员的 token:房间还没有 bot 成员时由存储用它创建,这个随机值不交给任何人。
func NewAPIKey() (string, port.NewAPIKey) {
key, hash := newSecret()
_, botHash := newSecret()
return key, port.NewAPIKey{TokenHash: hash, BotTokenHash: botHash}
return key, port.NewAPIKey{TokenHash: hash}
}
+97 -4
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"regexp"
"testing"
"testing/synctest"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
@@ -95,6 +96,23 @@ func TestUpdateProfile(t *testing.T) {
}
}
func TestUpdateProfileRejectsMalformedEmail(t *testing.T) {
s, _ := newService(t)
id := createMember(t, s).Member.ID
if _, err := s.UpdateProfile(t.Context(), id, Profile{Email: ptr("alice@example.com")}); err != nil {
t.Fatal(err)
}
for _, email := range []string{"a@b@c.com", "alice@example", "a lice@example.com", "alice@exa mple.com", "@", "@example.com", "alice@.", "alice"} {
if _, err := s.UpdateProfile(t.Context(), id, Profile{Email: ptr(email)}); !errors.Is(err, ErrInvalidEmail) {
t.Errorf("邮箱 %q 的错误 = %v,期望 ErrInvalidEmail", email, err)
}
}
if m, _ := s.UpdateProfile(t.Context(), id, Profile{}); m.AvatarHash != "c160f8cc69a4f0bf2b0362752353d060" {
t.Errorf("被拒绝的邮箱不应改动头像:%#v", m)
}
}
type noMemberBroadcast struct {
port.Bus
}
@@ -135,14 +153,88 @@ func TestIssueTicketFailsWhenPasswordChangesMeanwhile(t *testing.T) {
changed := passwordChangedStore{Store: store, newHash: hashPassword(t, "新密码")}
node := porttest.NewRedis().Node("a")
s := New(store, store, changed, newGateway(store, node, node))
alice := createMember(t, s).Member
alice := createMember(t, s)
id := createRoom(t, store, "旧密码")
if _, err := s.IssueTicket(t.Context(), alice.ID, id, "旧密码"); !errors.Is(err, ErrWrongPassword) {
if _, err := s.IssueTicket(t.Context(), alice.Member.ID, alice.MemberToken, id, "旧密码"); !errors.Is(err, ErrWrongPassword) {
t.Errorf("密码在校验之后被修改,错误 = %v,期望 ErrWrongPassword", err)
}
}
func TestTicketBelongsToIssuingSession(t *testing.T) {
s, store := newService(t)
alice := createMember(t, s)
id := createRoom(t, store, "")
ticket, err := s.IssueTicket(t.Context(), alice.Member.ID, alice.MemberToken, id, "")
if err != nil {
t.Fatal(err)
}
holder, err := s.VerifyTicket(t.Context(), ticket)
if err != nil || holder.Member != alice.Member || !bytes.Equal(holder.SessionTokenHash, hashSecret(alice.MemberToken)) {
t.Errorf("票据的持有者 = %#v, %v,期望属于 alice 签发它的会话", holder, err)
}
}
func TestIssueTicketRejectsDeletedSession(t *testing.T) {
s, store := newService(t)
alice := createMember(t, s)
id := createRoom(t, store, "")
if err := store.DeleteSession(t.Context(), hashSecret(alice.MemberToken)); err != nil {
t.Fatal(err)
}
if _, err := s.IssueTicket(t.Context(), alice.Member.ID, alice.MemberToken, id, ""); !errors.Is(err, ErrInvalidMemberToken) {
t.Errorf("会话在签发之前被删除,错误 = %v,期望 ErrInvalidMemberToken", err)
}
}
// 第一次查询照常,之后的查询都认为票据已经作废。
type revokedAfterFirstLookup struct {
port.Tickets
lookups int
}
func (r *revokedAfterFirstLookup) TicketHolder(ctx context.Context, tokenHash []byte) (port.TicketHolder, error) {
r.lookups++
if r.lookups > 1 {
return port.TicketHolder{}, port.ErrTicketNotFound
}
return r.Tickets.TicketHolder(ctx, tokenHash)
}
func TestEnterRoomLeavesWhenTicketIsRevokedWhileJoining(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(t.Context())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = host.Shutdown(context.Background())
_ = gateway.Shutdown(context.Background())
stop()
wait()
})
s := New(store, store, &revokedAfterFirstLookup{Tickets: store}, gateway)
alice := createMember(t, s)
id := createRoom(t, store, "")
ticket, err := s.IssueTicket(t.Context(), alice.Member.ID, alice.MemberToken, id, "")
if err != nil {
t.Fatal(err)
}
if _, err := s.EnterRoom(t.Context(), id, ticket); !errors.Is(err, ErrInvalidTicket) {
t.Errorf("加入期间票据被作废,错误 = %v,期望 ErrInvalidTicket", err)
}
if n := gateway.Sessions(); n != 0 {
t.Errorf("网关上残留 %d 个会话,期望 0", n)
}
})
}
type loggedSubscriptions struct {
port.Bus
logger *slog.Logger
@@ -160,12 +252,13 @@ func TestEnterRoomJoinsTheStoredRoom(t *testing.T) {
node := porttest.NewRedis().Node("a")
var logs bytes.Buffer
s := New(store, store, store, newGateway(store, node, loggedSubscriptions{node, slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil)))}))
alice := createMember(t, s).Member
session := createMember(t, s)
alice := session.Member
id, err := store.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "大厅", Slug: "hall"})
if err != nil {
t.Fatal(err)
}
ticket, err := s.IssueTicket(t.Context(), alice.ID, id, "")
ticket, err := s.IssueTicket(t.Context(), alice.ID, session.MemberToken, id, "")
if err != nil {
t.Fatal(err)
}
+9 -2
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"runtime"
"strings"
"sync"
"golang.org/x/crypto/argon2"
)
@@ -24,9 +25,9 @@ const (
var passwordPrefix = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$", argon2.Version, argonMemory, argonTime, argonThreads)
var errMalformedPasswordHash = errors.New("房间密码哈希的格式不正确")
var errMalformedPasswordHash = errors.New("密码哈希的格式不正确")
// 每次计算都要分配 argonMemory 的内存,而创建临时房间和换取票据任何人都能请求:不限制并发时,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。
// 每次计算都要分配 argonMemory 的内存,而创建临时房间、换取票据和登录任何人都能请求:不限制并发时,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。
var argonSlots = make(chan struct{}, runtime.GOMAXPROCS(0))
func idKey(ctx context.Context, password string, salt []byte) ([]byte, error) {
@@ -52,6 +53,12 @@ func HashPassword(ctx context.Context, password string) (string, error) {
return passwordPrefix + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(key), nil
}
// 用户名不存在时拿它做一次同样的计算,响应时间不暴露用户名是否存在。密码是随机值,不会与任何人输入的密码匹配。
var dummyPasswordHash = sync.OnceValues(func() (string, error) {
password, _ := newSecret()
return HashPassword(context.Background(), password)
})
// 只接受按当前参数生成的哈希:存储中的密码哈希都由 HashPassword 生成。
func checkPassword(ctx context.Context, hash, password string) (bool, error) {
encoded, ok := strings.CutPrefix(hash, passwordPrefix)
+15
View File
@@ -87,3 +87,18 @@ func TestPasswordWorkReturnsItsSlot(t *testing.T) {
}
}
}
func TestDummyPasswordHashMatchesNoPassword(t *testing.T) {
hash, err := dummyPasswordHash()
if err != nil {
t.Fatal(err)
}
if again, _ := dummyPasswordHash(); again != hash {
t.Error("假哈希应只生成一次")
}
for _, password := range []string{"", "correct horse", "12345678"} {
if ok, err := checkPassword(t.Context(), hash, password); err != nil || ok {
t.Errorf("checkPassword(假哈希, %q) = %v, %v,期望能解析且不匹配", password, ok, err)
}
}
}
+7
View File
@@ -38,3 +38,10 @@ func avatarHash(email string) string {
sum := md5.Sum([]byte(email))
return hex.EncodeToString(sum[:])
}
// 后台重置密码时生成:96 位随机值的 base64url 编码,共 16 个字符,方便转告用户。
func newPassword() string {
b := make([]byte, 12)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+1 -1
View File
@@ -47,7 +47,7 @@ func TestOnlineCount(t *testing.T) {
// 同一个成员的两个会话算两个在线。
alice := member.Member{ID: "00000000-0000-7000-8000-00000000000a", Name: "alice"}
for range 2 {
session, err := gateway.Join(t.Context(), busy, alice)
session, err := gateway.Join(t.Context(), busy, alice, nil)
if err != nil {
t.Fatal(err)
}
+189
View File
@@ -0,0 +1,189 @@
package playlist
import (
"context"
"errors"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/dihemoe/alisten/internal/app/catalog"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
const (
maxPlaylists = 50
maxItems = 1000
maxNameLength = 50
// 收藏可能要向音源查询元数据:额度防止批量请求让共用的音源账号被平台限流。
addBurst = 30
addRefill = 2 * time.Second
)
var (
ErrNotRegistered = errors.New("注册账号之后才能使用歌单")
ErrInvalidName = errors.New("name:去掉首尾空白后应有 1 到 50 个字符,其中至少一个看得见,不能包含控制字符")
ErrTooManyPlaylists = errors.New("最多只能建 50 个歌单")
ErrPlaylistFull = errors.New("一个歌单最多 1000 首歌")
ErrTrackNotReady = errors.New("这首歌还在准备,准备好之后再保存")
ErrTooFast = errors.New("操作太频繁,请稍后再试。")
)
type Service struct {
playlists port.Playlists
members port.Members
tracks port.Tracks
catalog *catalog.Service
quota port.AccountQuota
}
func New(playlists port.Playlists, members port.Members, tracks port.Tracks, catalog *catalog.Service, quota port.AccountQuota) *Service {
return &Service{playlists: playlists, members: members, tracks: tracks, catalog: catalog, quota: quota}
}
func (s *Service) List(ctx context.Context, memberID string) ([]port.PlaylistRecord, error) {
if err := s.registered(ctx, memberID); err != nil {
return nil, err
}
return s.playlists.MemberPlaylists(ctx, memberID)
}
func (s *Service) Create(ctx context.Context, memberID, name string) (port.PlaylistRecord, error) {
if err := s.registered(ctx, memberID); err != nil {
return port.PlaylistRecord{}, err
}
name, err := validName(name)
if err != nil {
return port.PlaylistRecord{}, err
}
p, err := s.playlists.CreatePlaylist(ctx, memberID, name, maxPlaylists)
if errors.Is(err, port.ErrPlaylistLimit) {
return port.PlaylistRecord{}, ErrTooManyPlaylists
}
return p, err
}
func (s *Service) Rename(ctx context.Context, memberID, id, name string) error {
if err := s.registered(ctx, memberID); err != nil {
return err
}
name, err := validName(name)
if err != nil {
return err
}
return s.playlists.RenamePlaylist(ctx, memberID, id, name)
}
func (s *Service) Delete(ctx context.Context, memberID, id string) error {
if err := s.registered(ctx, memberID); err != nil {
return err
}
return s.playlists.DeletePlaylist(ctx, memberID, id)
}
func (s *Service) Items(ctx context.Context, memberID, id string) ([]port.PlaylistItem, error) {
if err := s.registered(ctx, memberID); err != nil {
return nil, err
}
return s.playlists.PlaylistItems(ctx, memberID, id)
}
// 链接曲目的 t.ID 必须已经按 track.LinkID 规范化。
func (s *Service) Add(ctx context.Context, memberID, id string, t track.Track) (port.PlaylistItem, error) {
if err := s.registered(ctx, memberID); err != nil {
return port.PlaylistItem{}, err
}
wait, err := s.quota.Take(ctx, "playlist:member:"+memberID, addBurst, addRefill)
if err != nil {
return port.PlaylistItem{}, err
}
if wait > 0 {
return port.PlaylistItem{}, ErrTooFast
}
meta, err := s.meta(ctx, t)
if err != nil {
return port.PlaylistItem{}, err
}
item := port.PlaylistItem{Track: t, Meta: meta}
err = s.playlists.AddPlaylistItem(ctx, memberID, id, item, maxItems)
if errors.Is(err, port.ErrPlaylistLimit) {
return port.PlaylistItem{}, ErrPlaylistFull
}
if err != nil {
return port.PlaylistItem{}, err
}
return item, nil
}
func (s *Service) Remove(ctx context.Context, memberID, id string, tracks []track.Track) error {
if err := s.registered(ctx, memberID); err != nil {
return err
}
return s.playlists.RemovePlaylistItems(ctx, memberID, id, tracks)
}
func (s *Service) Containing(ctx context.Context, memberID string, t track.Track) ([]string, error) {
if err := s.registered(ctx, memberID); err != nil {
return nil, err
}
return s.playlists.TrackPlaylists(ctx, memberID, t)
}
func (s *Service) Copy(ctx context.Context, memberID, fromID, toID string, tracks []track.Track) (int, error) {
if err := s.registered(ctx, memberID); err != nil {
return 0, err
}
n, err := s.playlists.CopyPlaylistItems(ctx, memberID, fromID, toID, tracks, maxItems)
if errors.Is(err, port.ErrPlaylistLimit) {
return 0, ErrPlaylistFull
}
return n, err
}
func (s *Service) Move(ctx context.Context, memberID, id string, tracks []track.Track, before *track.Track) error {
if err := s.registered(ctx, memberID); err != nil {
return err
}
return s.playlists.MovePlaylistItems(ctx, memberID, id, tracks, before)
}
func (s *Service) registered(ctx context.Context, memberID string) error {
profile, err := s.members.AccountProfile(ctx, memberID)
if err != nil {
return err
}
if profile.Username == "" {
return ErrNotRegistered
}
return nil
}
// 链接曲目准备好之前没有元数据,音源也查不到。
func (s *Service) meta(ctx context.Context, t track.Track) (track.Meta, error) {
if t.Source != track.Link {
return s.catalog.TrackMeta(ctx, t)
}
p, ok, err := s.tracks.PreparedTrack(ctx, t)
if err != nil {
return track.Meta{}, err
}
if !ok {
return track.Meta{}, ErrTrackNotReady
}
return p.Meta, nil
}
func validName(name string) (string, error) {
name = strings.TrimSpace(name)
if utf8.RuneCountInString(name) > maxNameLength || strings.ContainsFunc(name, unicode.IsControl) || !strings.ContainsFunc(name, visible) {
return "", ErrInvalidName
}
return name, nil
}
// unicode.IsGraphic 把 Zs 类的空白也算作 graphic。
func visible(r rune) bool {
return unicode.IsGraphic(r) && !unicode.IsSpace(r)
}
+293
View File
@@ -0,0 +1,293 @@
package playlist
import (
"context"
"crypto/sha256"
"errors"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/catalog"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/member"
playlistorder "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
sunny = track.Track{Source: track.NetEase, ID: "186016"}
sunnyMeta = 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}
clip = track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
)
type tracksTable map[track.Track]port.PreparedTrack
func (t tracksTable) PreparedTrack(_ context.Context, k track.Track) (port.PreparedTrack, bool, error) {
p, ok := t[k]
return p, ok, nil
}
func (tracksTable) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
return nil
}
type noHistory struct{}
func (noHistory) AutoDJSeeds(context.Context, string, bool) ([]track.Track, error) { return nil, nil }
func (noHistory) AutoDJExclusions(context.Context, string) ([]track.Track, error) { return nil, nil }
type roomQuota struct{}
func (roomQuota) TakeSearch(context.Context, string, int, time.Duration) (time.Duration, error) {
return 0, nil
}
func (roomQuota) TakePick(context.Context, string, int, time.Duration) (time.Duration, error) {
return 0, nil
}
type accountQuota struct {
exhausted bool
keys []string
}
func (q *accountQuota) Take(_ context.Context, key string, _ int, _ time.Duration) (time.Duration, error) {
q.keys = append(q.keys, key)
if q.exhausted {
return time.Second, nil
}
return 0, nil
}
type fixture struct {
svc *Service
store *porttest.Store
tracks tracksTable
quota *accountQuota
}
func newFixture(t *testing.T) fixture {
t.Helper()
store := porttest.New()
netease := porttest.NewSource(track.NetEase)
netease.Add(sunny.ID, sunnyMeta, "", nil)
bilibili := porttest.NewBilibili()
tracks := tracksTable{}
music := catalog.New(netease, porttest.NewSource(track.QQ), bilibili, bilibili, tracks, noHistory{}, store, roomQuota{})
q := &accountQuota{}
return fixture{svc: New(store, store, tracks, music, q), store: store, tracks: tracks, quota: q}
}
func (f fixture) member(t *testing.T, username string) member.Member {
t.Helper()
hash := sha256.Sum256([]byte(username))
m, err := f.store.CreateMember(t.Context(), hash[:], "游客-"+username)
if err != nil {
t.Fatal(err)
}
if username != "" {
if err := f.store.Register(t.Context(), m.ID, username, "hash"); err != nil {
t.Fatal(err)
}
}
return m
}
func (f fixture) favorites(t *testing.T, memberID string) string {
t.Helper()
lists, err := f.svc.List(t.Context(), memberID)
if err != nil || len(lists) == 0 || !lists[0].Favorites {
t.Fatalf("歌单 = %#v, %v", lists, err)
}
return lists[0].ID
}
func TestGuestCannotUsePlaylists(t *testing.T) {
f := newFixture(t)
guest := f.member(t, "")
if _, err := f.svc.List(t.Context(), guest.ID); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客读取歌单 = %v,期望 ErrNotRegistered", err)
}
if _, err := f.svc.Create(t.Context(), guest.ID, "通勤"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客新建歌单 = %v,期望 ErrNotRegistered", err)
}
if _, err := f.svc.Add(t.Context(), guest.ID, "any", sunny); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客收藏 = %v,期望 ErrNotRegistered", err)
}
if _, err := f.svc.Containing(t.Context(), guest.ID, sunny); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客查询所在的歌单 = %v,期望 ErrNotRegistered", err)
}
if err := f.svc.Remove(t.Context(), guest.ID, "any", []track.Track{sunny}); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客批量移出 = %v,期望 ErrNotRegistered", err)
}
if _, err := f.svc.Copy(t.Context(), guest.ID, "from", "to", []track.Track{sunny}); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客复制 = %v,期望 ErrNotRegistered", err)
}
if err := f.svc.Move(t.Context(), guest.ID, "any", []track.Track{sunny}, nil); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客调整顺序 = %v,期望 ErrNotRegistered", err)
}
if len(f.quota.keys) != 0 {
t.Errorf("游客不应消耗额度:%v", f.quota.keys)
}
}
func TestCreateValidatesName(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
p, err := f.svc.Create(t.Context(), alice.ID, " 通勤 ")
if err != nil || p.Name != "通勤" {
t.Fatalf("Create = %#v, %v,期望去掉首尾空白", p, err)
}
for _, name := range []string{"", " ", strings.Repeat("歌", 51), "通\n勤", "\u200b"} {
if _, err := f.svc.Create(t.Context(), alice.ID, name); !errors.Is(err, ErrInvalidName) {
t.Errorf("Create(%q) = %v,期望 ErrInvalidName", name, err)
}
if err := f.svc.Rename(t.Context(), alice.ID, p.ID, name); !errors.Is(err, ErrInvalidName) {
t.Errorf("Rename(%q) = %v,期望 ErrInvalidName", name, err)
}
}
if _, err := f.svc.Create(t.Context(), alice.ID, strings.Repeat("歌", 50)); err != nil {
t.Errorf("50 个字的歌单名 = %v,期望可以", err)
}
}
func TestCreateStopsAtFiftyPlaylists(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
for i := range 49 {
if _, err := f.svc.Create(t.Context(), alice.ID, "歌单"+strconv.Itoa(i)); err != nil {
t.Fatal(err)
}
}
if _, err := f.svc.Create(t.Context(), alice.ID, "第 51 个"); !errors.Is(err, ErrTooManyPlaylists) {
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
}
}
func TestAddTakesMetaFromTheServer(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
favorites := f.favorites(t, alice.ID)
item, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny)
if want := (port.PlaylistItem{Track: sunny, Meta: sunnyMeta}); err != nil || item != want {
t.Fatalf("Add = %#v, %v,期望 %#v", item, err, want)
}
if items, err := f.svc.Items(t.Context(), alice.ID, favorites); err != nil || !slices.Equal(items, []port.PlaylistItem{item}) {
t.Errorf("Items = %#v, %v", items, err)
}
if want := []string{"playlist:member:" + alice.ID}; !slices.Equal(f.quota.keys, want) {
t.Errorf("取的额度 = %v,期望 %v", f.quota.keys, want)
}
if err := f.svc.Remove(t.Context(), alice.ID, favorites, []track.Track{sunny}); err != nil {
t.Fatal(err)
}
if items, _ := f.svc.Items(t.Context(), alice.ID, favorites); len(items) != 0 {
t.Errorf("移除之后 = %#v,期望为空", items)
}
}
func TestAddLinkNeedsPreparedTrack(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
favorites := f.favorites(t, alice.ID)
if _, err := f.svc.Add(t.Context(), alice.ID, favorites, clip); !errors.Is(err, ErrTrackNotReady) {
t.Errorf("没准备过的链接 = %v,期望 ErrTrackNotReady", err)
}
meta := track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: clip.ID, Duration: time.Minute}
f.tracks[clip] = port.PreparedTrack{Meta: meta}
if item, err := f.svc.Add(t.Context(), alice.ID, favorites, clip); err != nil || item.Meta != meta {
t.Errorf("准备过的链接 = %#v, %v,期望用曲目表里的元数据", item, err)
}
}
func TestAddIsRateLimited(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
favorites := f.favorites(t, alice.ID)
f.quota.exhausted = true
if _, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny); !errors.Is(err, ErrTooFast) {
t.Errorf("额度用完时 = %v,期望 ErrTooFast", err)
}
if items, _ := f.svc.Items(t.Context(), alice.ID, favorites); len(items) != 0 {
t.Errorf("额度用完时不应加入:%#v", items)
}
}
type fullPlaylists struct{ *porttest.Store }
func (fullPlaylists) AddPlaylistItem(context.Context, string, string, port.PlaylistItem, int) error {
return port.ErrPlaylistLimit
}
func (fullPlaylists) CopyPlaylistItems(context.Context, string, string, string, []track.Track, int) (int, error) {
return 0, port.ErrPlaylistLimit
}
func TestAddReportsFullPlaylist(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
favorites := f.favorites(t, alice.ID)
f.svc.playlists = fullPlaylists{f.store}
if _, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny); !errors.Is(err, ErrPlaylistFull) {
t.Errorf("歌单满了时 = %v,期望 ErrPlaylistFull", err)
}
}
func TestFavoritesStayFixed(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
favorites := f.favorites(t, alice.ID)
if err := f.svc.Rename(t.Context(), alice.ID, favorites, "别的"); !errors.Is(err, port.ErrFavoritesFixed) {
t.Errorf("“我喜欢”改名 = %v,期望 ErrFavoritesFixed", err)
}
if err := f.svc.Delete(t.Context(), alice.ID, favorites); !errors.Is(err, port.ErrFavoritesFixed) {
t.Errorf("删除“我喜欢” = %v,期望 ErrFavoritesFixed", err)
}
}
func TestCopyReportsFullPlaylist(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
favorites := f.favorites(t, alice.ID)
commute, err := f.svc.Create(t.Context(), alice.ID, "通勤")
if err != nil {
t.Fatal(err)
}
if _, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny); err != nil {
t.Fatal(err)
}
if n, err := f.svc.Copy(t.Context(), alice.ID, favorites, commute.ID, []track.Track{sunny}); err != nil || n != 1 {
t.Errorf("复制 = %d, %v,期望 1", n, err)
}
if len(f.quota.keys) != 1 {
t.Errorf("复制不应取收藏额度:%v", f.quota.keys)
}
f.svc.playlists = fullPlaylists{f.store}
if _, err := f.svc.Copy(t.Context(), alice.ID, favorites, commute.ID, []track.Track{sunny}); !errors.Is(err, ErrPlaylistFull) {
t.Errorf("歌单满了时 = %v,期望 ErrPlaylistFull", err)
}
}
func TestMoveReordersAndReportsDomainErrors(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
favorites := f.favorites(t, alice.ID)
if _, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny); err != nil {
t.Fatal(err)
}
if err := f.svc.Move(t.Context(), alice.ID, favorites, []track.Track{sunny}, &sunny); !errors.Is(err, playlistorder.ErrBeforeMoved) {
t.Errorf("before 是被移动的歌 = %v,期望 ErrBeforeMoved", err)
}
missing := track.Track{Source: track.NetEase, ID: "1"}
if err := f.svc.Move(t.Context(), alice.ID, favorites, []track.Track{sunny}, &missing); !errors.Is(err, playlistorder.ErrBeforeMissing) {
t.Errorf("before 不在歌单里 = %v,期望 ErrBeforeMissing", err)
}
if err := f.svc.Move(t.Context(), alice.ID, favorites, []track.Track{sunny}, nil); err != nil {
t.Errorf("移到最后 = %v", err)
}
}
-2
View File
@@ -8,8 +8,6 @@ import (
// 常驻房间的 API Key 是房间 bot 成员的票据。
type NewAPIKey struct {
TokenHash []byte
// 房间还没有 bot 成员时用它创建:对应的随机值不交给任何人,bot 因此没有可用的成员 token。
BotTokenHash []byte
}
type APIKeyStatus struct {
+14 -6
View File
@@ -92,19 +92,25 @@ type (
Outcome PrepareOutcome
}
// 这些会话已经删除,用它们签发的票据建立的会话和事件流要以 4401 断开。
SessionsRevokedDelivery struct {
SessionTokenHashes [][]byte
}
// 订阅连接曾经断开,断开期间发给本实例的消息都已丢失。
SubscriptionLost struct{}
)
func (RoomDelivery) delivery() {}
func (InputDelivery) delivery() {}
func (MemberDelivery) delivery() {}
func (PreparedDelivery) delivery() {}
func (SubscriptionLost) delivery() {}
func (RoomDelivery) delivery() {}
func (InputDelivery) delivery() {}
func (MemberDelivery) delivery() {}
func (PreparedDelivery) delivery() {}
func (SessionsRevokedDelivery) delivery() {}
func (SubscriptionLost) delivery() {}
// 实现绑定在本实例上:订阅的是本实例的频道,发出的消息带有本实例的 ID。
type Bus interface {
// 订阅本实例的频道、成员资料的频道和准备结果的频道,返回时订阅已经生效。
// 订阅本实例的频道、成员的频道和准备结果的频道,返回时订阅已经生效。
SubscribeInstance(ctx context.Context) error
// 返回时订阅已经生效:此后发布到这个房间频道的消息都会收到。
SubscribeRoom(ctx context.Context, roomID string) error
@@ -114,6 +120,8 @@ type Bus interface {
SendInput(ctx context.Context, instanceID string, in RoomInput) (bool, error)
PublishMember(ctx context.Context, m member.Member) error
// 各实例都会收到,包括发布的实例。
PublishSessionsRevoked(ctx context.Context, sessionTokenHashes [][]byte) error
// 各实例都会收到,包括发布的实例。
PublishPrepared(ctx context.Context, o PrepareOutcome) error
// 按收到的顺序返回下一条消息,直到 ctx 结束;ctx 结束之后 Bus 不能再使用。只能由一个 goroutine 调用。
Receive(ctx context.Context) (Delivery, error)
+40 -4
View File
@@ -7,12 +7,48 @@ import (
"github.com/dihemoe/alisten/internal/domain/member"
)
var ErrMemberNotFound = errors.New("成员不存在")
var (
ErrMemberNotFound = errors.New("成员不存在")
ErrUsernameTaken = errors.New("用户名已被占用")
ErrAlreadyRegistered = errors.New("已经注册过了")
ErrSessionNotFound = errors.New("会话不存在")
ErrAccountPasswordChanged = errors.New("账号密码已修改")
)
// 成员 token 只以 SHA-256 哈希的形式出现在端口中。
// 设置了用户名的成员。
type Account struct {
Member member.Member
Username string
Email string
PasswordHash string
}
// 游客的两个字段都为空。
type AccountProfile struct {
Username string
Email string
}
// 成员 token 只以 SHA-256 哈希的形式出现在端口中。一个成员 token 是成员的一个会话,同一个成员可以有多个会话。
// 删除会话时,签发给这个会话的票据一并删除。
type Members interface {
// 创建游客和它的第一个会话。
CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error)
MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error)
// name 或 avatarHash 为 nil 时保持原值。
UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error)
// name、avatarHash 或 email 为 nil 时保持原值,email 为空字符串表示清除。游客的 email 不保存。
UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error)
AccountProfile(ctx context.Context, memberID string) (AccountProfile, error)
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。同时为这个账号创建“我喜欢”。
Register(ctx context.Context, memberID, username, passwordHash string) error
AccountByUsername(ctx context.Context, username string) (Account, error)
// 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。oldTokenHash 的会话已经不存在时返回
// ErrSessionNotFound;memberID 的密码哈希已经不是 passwordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改过,
// 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。
ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error
DeleteSession(ctx context.Context, tokenHash []byte) error
// 在同一个事务中修改已注册成员的密码哈希,并删除该成员的全部会话。返回被删除的会话的 token 哈希。成员是游客时返回 ErrMemberNotFound。
SetPassword(ctx context.Context, memberID, passwordHash string) ([][]byte, error)
// 与 SetPassword 相同,但保留发出请求的 keepTokenHash 的会话。这个会话已经不存在时返回 ErrSessionNotFound;密码哈希已经不是
// 校验当前密码时读到的 currentPasswordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改或重置过。两种情况都不做任何修改。
ChangePassword(ctx context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) ([][]byte, error)
}
+62
View File
@@ -0,0 +1,62 @@
package port
import (
"context"
"errors"
"time"
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
ErrPlaylistNotFound = errors.New("歌单不存在")
ErrFavoritesFixed = errors.New("“我喜欢”不能重命名或删除")
ErrPlaylistLimit = errors.New("歌单或歌单里的歌已达上限")
)
type PlaylistRecord struct {
ID string
Name string
Favorites bool
TrackCount int
CreatedAt time.Time
UpdatedAt time.Time
Cover *Cover
}
// 链接曲目的封面在对象存储中,只有对象键。
type Cover struct {
URL string
Key track.ObjectKey
}
type PlaylistItem struct {
Track track.Track
Meta track.Meta
}
// 所有方法都按 ownerID 过滤:别人的歌单和不存在的歌单一样,返回 ErrPlaylistNotFound。“我喜欢”由 Members.Register 创建。
type Playlists interface {
// “我喜欢”在最前,其余按创建的先后。Cover 是歌单里排在最前、有封面的那首歌的封面,没有时为 nil。
MemberPlaylists(ctx context.Context, ownerID string) ([]PlaylistRecord, error)
// 已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。
CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (PlaylistRecord, error)
// “我喜欢”返回 ErrFavoritesFixed。
RenamePlaylist(ctx context.Context, ownerID, id, name string) error
// “我喜欢”返回 ErrFavoritesFixed。
DeletePlaylist(ctx context.Context, ownerID, id string) error
// 新加入的在前。
PlaylistItems(ctx context.Context, ownerID, id string) ([]PlaylistItem, error)
// 已经在歌单里时什么也不做;已有 limit 首时返回 ErrPlaylistLimit。
AddPlaylistItem(ctx context.Context, ownerID, id string, item PlaylistItem, limit int) error
// 不在歌单里的歌忽略。
RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error
// 只看这个成员自己的歌单;没有命中时返回空切片。
TrackPlaylists(ctx context.Context, ownerID string, t track.Track) ([]string, error)
// 把 fromID 歌单里的这些歌连同元数据复制到 toID 歌单,返回复制的数量。fromID 不属于 ownerID 时返回 ErrPlaylistNotFound。
// 不在 fromID 里的和已在 toID 里的跳过;新复制的排在最前,tracks[0] 最靠前。
// 复制之后超过 limit 首时整批拒绝,返回 ErrPlaylistLimit。
CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error)
// 按 playlist.Move 的规则调整顺序,错误原样返回。
MovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track, before *track.Track) error
}
+5
View File
@@ -282,6 +282,11 @@ func (n *Node) PublishMember(_ context.Context, m member.Member) error {
return err
}
func (n *Node) PublishSessionsRevoked(_ context.Context, sessionTokenHashes [][]byte) error {
_, err := n.publish("members", port.SessionsRevokedDelivery{SessionTokenHashes: sessionTokenHashes})
return err
}
func (n *Node) PublishPrepared(_ context.Context, o port.PrepareOutcome) error {
_, err := n.publish("tracks.prepared", port.PreparedDelivery{Outcome: o})
return err
+407 -28
View File
@@ -15,7 +15,9 @@ import (
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
"github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/room"
"github.com/dihemoe/alisten/internal/domain/track"
)
// Store 的行为与 postgres 适配器一致,包括它会拒绝的输入。
@@ -23,23 +25,30 @@ type Store struct {
mu sync.Mutex
ids int
members []storedMember
rooms []port.RoomRecord
states map[string]*roomState
tickets map[string]storedTicket
// 会话的 token 哈希到成员 ID。
sessions map[string]string
rooms []port.RoomRecord
states map[string]*roomState
tickets map[string]storedTicket
// 房间 ID 到 bot 成员 ID。
bots map[string]string
cookies map[string]string
brandName string
bots map[string]string
cookies map[string]string
brandName string
registrationClosed bool
playlists []*storedPlaylist
}
type storedMember struct {
tokenHash string
member member.Member
member member.Member
username string
email string
passwordHash string
}
type storedTicket struct {
roomID string
memberID string
session string
createdAt time.Time
}
@@ -50,14 +59,16 @@ var (
_ port.Credentials = (*Store)(nil)
_ port.APIKeys = (*Store)(nil)
_ port.Site = (*Store)(nil)
_ port.Playlists = (*Store)(nil)
)
func New() *Store {
return &Store{
states: map[string]*roomState{},
tickets: map[string]storedTicket{},
bots: map[string]string{},
cookies: map[string]string{},
states: map[string]*roomState{},
sessions: map[string]string{},
tickets: map[string]storedTicket{},
bots: map[string]string{},
cookies: map[string]string{},
}
}
@@ -84,35 +95,48 @@ func (s *Store) CreateMember(_ context.Context, tokenHash []byte, name string) (
}
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if m.tokenHash == string(tokenHash) {
return member.Member{}, errors.New("token 哈希重复")
}
if _, ok := s.sessions[string(tokenHash)]; ok {
return member.Member{}, errors.New("token 哈希重复")
}
m := member.Member{ID: s.newID(), Name: name}
s.members = append(s.members, storedMember{tokenHash: string(tokenHash), member: m})
s.members = append(s.members, storedMember{member: m})
s.sessions[string(tokenHash)] = m.ID
return m, nil
}
func (s *Store) MemberByToken(_ context.Context, tokenHash []byte) (member.Member, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if m.tokenHash == string(tokenHash) {
return m.member, nil
}
if m := s.member(s.sessions[string(tokenHash)]); m != nil {
return m.member, nil
}
return member.Member{}, port.ErrMemberNotFound
}
func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *string) (member.Member, error) {
// 调用方持有 s.mu。
func (s *Store) member(id string) *storedMember {
for i := range s.members {
if s.members[i].member.ID == id {
return &s.members[i]
}
}
return nil
}
func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash, email *string) (member.Member, error) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.members {
m := &s.members[i].member
stored := &s.members[i]
m := &stored.member
if m.ID != id {
continue
}
if email != nil {
if err := checkText(*email); err != nil {
return member.Member{}, err
}
}
if name != nil {
if err := checkText(*name); err != nil {
return member.Member{}, err
@@ -122,11 +146,130 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *str
if avatarHash != nil {
m.AvatarHash = *avatarHash
}
if email != nil && stored.username != "" {
stored.email = *email
}
return *m, nil
}
return member.Member{}, port.ErrMemberNotFound
}
func (s *Store) AccountProfile(_ context.Context, memberID string) (port.AccountProfile, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return port.AccountProfile{}, port.ErrMemberNotFound
}
return port.AccountProfile{Username: m.username, Email: m.email}, nil
}
func (s *Store) Register(_ context.Context, memberID, username, passwordHash string) error {
if err := checkText(username, passwordHash); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username != "" {
return port.ErrAlreadyRegistered
}
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.username == username }) {
return port.ErrUsernameTaken
}
m.username, m.passwordHash = username, passwordHash
now := time.Now()
s.playlists = append(s.playlists, &storedPlaylist{id: s.newID(), owner: memberID, name: "我喜欢", favorites: true, created: now, updated: now})
return nil
}
func (s *Store) AccountByUsername(_ context.Context, username string) (port.Account, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if username != "" && m.username == username {
return port.Account{Member: m.member, Username: m.username, Email: m.email, PasswordHash: m.passwordHash}, nil
}
}
return port.Account{}, port.ErrMemberNotFound
}
func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return fmt.Errorf("成员 %s 不存在", memberID)
}
if m.passwordHash != passwordHash {
return port.ErrAccountPasswordChanged
}
if _, ok := s.sessions[string(oldTokenHash)]; !ok {
return port.ErrSessionNotFound
}
if _, ok := s.sessions[string(newTokenHash)]; ok {
return errors.New("token 哈希重复")
}
s.deleteSession(string(oldTokenHash))
s.sessions[string(newTokenHash)] = memberID
return nil
}
func (s *Store) DeleteSession(_ context.Context, tokenHash []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
s.deleteSession(string(tokenHash))
return nil
}
func (s *Store) SetPassword(_ context.Context, memberID, passwordHash string) ([][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username == "" {
return nil, port.ErrMemberNotFound
}
m.passwordHash = passwordHash
return s.deleteSessions(memberID, nil), nil
}
func (s *Store) ChangePassword(_ context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username == "" {
return nil, port.ErrMemberNotFound
}
if owner, ok := s.sessions[string(keepTokenHash)]; !ok || owner != memberID {
return nil, port.ErrSessionNotFound
}
if m.passwordHash != currentPasswordHash {
return nil, port.ErrAccountPasswordChanged
}
m.passwordHash = passwordHash
return s.deleteSessions(memberID, keepTokenHash), nil
}
// 调用方持有 s.mu。
func (s *Store) deleteSessions(memberID string, keepTokenHash []byte) [][]byte {
var deleted [][]byte
for hash, id := range s.sessions {
if id == memberID && (keepTokenHash == nil || hash != string(keepTokenHash)) {
deleted = append(deleted, []byte(hash))
}
}
for _, hash := range deleted {
s.deleteSession(string(hash))
}
return deleted
}
// 与 postgres 的外键一致:会话的票据随会话一起删除。调用方持有 s.mu。
func (s *Store) deleteSession(hash string) {
delete(s.sessions, hash)
maps.DeleteFunc(s.tickets, func(_ string, t storedTicket) bool { return t.session == hash })
}
func (s *Store) CreateTicket(_ context.Context, t port.NewTicket) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -140,10 +283,13 @@ func (s *Store) CreateTicket(_ context.Context, t port.NewTicket) error {
if !slices.ContainsFunc(s.members, func(m storedMember) bool { return m.member.ID == t.MemberID }) {
return errors.New("成员不存在")
}
if _, ok := s.sessions[string(t.SessionTokenHash)]; t.SessionTokenHash != nil && !ok {
return port.ErrSessionNotFound
}
if _, ok := s.tickets[string(t.TokenHash)]; ok {
return errors.New("票据哈希重复")
}
s.tickets[string(t.TokenHash)] = storedTicket{roomID: r.Info.ID, memberID: t.MemberID, createdAt: time.Now()}
s.tickets[string(t.TokenHash)] = storedTicket{roomID: r.Info.ID, memberID: t.MemberID, session: string(t.SessionTokenHash), createdAt: time.Now()}
return nil
}
@@ -156,7 +302,11 @@ func (s *Store) TicketHolder(_ context.Context, tokenHash []byte) (port.TicketHo
}
for _, m := range s.members {
if m.member.ID == t.memberID {
return port.TicketHolder{RoomID: t.roomID, Member: m.member, Bot: s.bots[t.roomID] == t.memberID}, nil
holder := port.TicketHolder{RoomID: t.roomID, Member: m.member, Bot: s.bots[t.roomID] == t.memberID}
if t.session != "" {
holder.SessionTokenHash = []byte(t.session)
}
return holder, nil
}
}
return port.TicketHolder{}, port.ErrTicketNotFound
@@ -194,7 +344,7 @@ func (s *Store) ResetRoomAPIKey(_ context.Context, roomID string, k port.NewAPIK
maps.DeleteFunc(s.tickets, func(_ string, t storedTicket) bool { return t.roomID == r.Info.ID && t.memberID == bot })
} else {
bot = s.newID()
s.members = append(s.members, storedMember{tokenHash: string(k.BotTokenHash), member: member.Member{ID: bot, Name: "bot"}})
s.members = append(s.members, storedMember{member: member.Member{ID: bot, Name: "bot"}})
s.bots[r.Info.ID] = bot
}
s.tickets[string(k.TokenHash)] = storedTicket{roomID: r.Info.ID, memberID: bot, createdAt: time.Now()}
@@ -329,10 +479,17 @@ func (s *Store) SetBilibiliCredential(_ context.Context, credential string) erro
return nil
}
func (s *Store) BrandName(context.Context) (string, error) {
func (s *Store) Settings(context.Context) (port.SiteSettings, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.brandName, nil
return port.SiteSettings{BrandName: s.brandName, RegistrationOpen: !s.registrationClosed}, nil
}
func (s *Store) SetRegistrationOpen(_ context.Context, open bool) error {
s.mu.Lock()
defer s.mu.Unlock()
s.registrationClosed = !open
return nil
}
func (s *Store) SetBrandName(_ context.Context, name string) error {
@@ -362,3 +519,225 @@ func (s *Store) slugOwner(slug string) *port.RoomRecord {
}
return nil
}
type storedPlaylist struct {
id, owner, name string
favorites bool
items []port.PlaylistItem
created, updated time.Time
}
func (p *storedPlaylist) cover() *port.Cover {
for i := len(p.items) - 1; i >= 0; i-- {
if m := p.items[i].Meta; m.CoverURL != "" || m.CoverKey != "" {
return &port.Cover{URL: m.CoverURL, Key: m.CoverKey}
}
}
return nil
}
// 调用方持有 s.mu。
func (s *Store) playlist(ownerID, id string) *storedPlaylist {
for _, p := range s.playlists {
if p.id == id && p.owner == ownerID {
return p
}
}
return nil
}
func (s *Store) MemberPlaylists(_ context.Context, ownerID string) ([]port.PlaylistRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
var lists []port.PlaylistRecord
for _, p := range s.playlists {
if p.owner != ownerID {
continue
}
r := port.PlaylistRecord{
ID: p.id,
Name: p.name,
Favorites: p.favorites,
TrackCount: len(p.items),
CreatedAt: p.created,
UpdatedAt: p.updated,
Cover: p.cover(),
}
if p.favorites {
lists = append([]port.PlaylistRecord{r}, lists...)
} else {
lists = append(lists, r)
}
}
return lists, nil
}
func (s *Store) CreatePlaylist(_ context.Context, ownerID, name string, limit int) (port.PlaylistRecord, error) {
if err := checkText(name); err != nil {
return port.PlaylistRecord{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, p := range s.playlists {
if p.owner == ownerID {
n++
}
}
if n >= limit {
return port.PlaylistRecord{}, port.ErrPlaylistLimit
}
now := time.Now()
p := &storedPlaylist{id: s.newID(), owner: ownerID, name: name, created: now, updated: now}
s.playlists = append(s.playlists, p)
return port.PlaylistRecord{ID: p.id, Name: name, CreatedAt: now, UpdatedAt: now}, nil
}
func (s *Store) RenamePlaylist(_ context.Context, ownerID, id, name string) error {
if err := checkText(name); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
switch {
case p == nil:
return port.ErrPlaylistNotFound
case p.favorites:
return port.ErrFavoritesFixed
}
p.name = name
p.updated = time.Now()
return nil
}
func (s *Store) DeletePlaylist(_ context.Context, ownerID, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
switch {
case p == nil:
return port.ErrPlaylistNotFound
case p.favorites:
return port.ErrFavoritesFixed
}
s.playlists = slices.DeleteFunc(s.playlists, func(other *storedPlaylist) bool { return other == p })
return nil
}
func (s *Store) PlaylistItems(_ context.Context, ownerID, id string) ([]port.PlaylistItem, error) {
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
if p == nil {
return nil, port.ErrPlaylistNotFound
}
items := make([]port.PlaylistItem, 0, len(p.items))
for i := len(p.items) - 1; i >= 0; i-- {
items = append(items, p.items[i])
}
return items, nil
}
func (s *Store) AddPlaylistItem(_ context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
m := item.Meta
if err := checkText(item.Track.ID, m.Title, m.Artist, m.Album, m.CoverURL, string(m.CoverKey), m.PageURL); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
if p == nil {
return port.ErrPlaylistNotFound
}
if slices.ContainsFunc(p.items, func(i port.PlaylistItem) bool { return i.Track == item.Track }) {
return nil
}
if len(p.items) >= limit {
return port.ErrPlaylistLimit
}
p.items = append(p.items, item)
p.updated = time.Now()
return nil
}
func (s *Store) RemovePlaylistItems(_ context.Context, ownerID, id string, tracks []track.Track) error {
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
if p == nil {
return port.ErrPlaylistNotFound
}
n := len(p.items)
p.items = slices.DeleteFunc(p.items, func(i port.PlaylistItem) bool { return slices.Contains(tracks, i.Track) })
if len(p.items) < n {
p.updated = time.Now()
}
return nil
}
func (s *Store) TrackPlaylists(_ context.Context, ownerID string, t track.Track) ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
ids := []string{}
for _, p := range s.playlists {
if p.owner == ownerID && slices.ContainsFunc(p.items, func(i port.PlaylistItem) bool { return i.Track == t }) {
ids = append(ids, p.id)
}
}
return ids, nil
}
func (s *Store) CopyPlaylistItems(_ context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
from, to := s.playlist(ownerID, fromID), s.playlist(ownerID, toID)
if from == nil || to == nil {
return 0, port.ErrPlaylistNotFound
}
var fresh []port.PlaylistItem
for _, t := range tracks {
i := slices.IndexFunc(from.items, func(i port.PlaylistItem) bool { return i.Track == t })
if i < 0 || slices.ContainsFunc(to.items, func(i port.PlaylistItem) bool { return i.Track == t }) ||
slices.ContainsFunc(fresh, func(i port.PlaylistItem) bool { return i.Track == t }) {
continue
}
fresh = append(fresh, from.items[i])
}
if len(fresh) == 0 {
return 0, nil
}
if len(to.items)+len(fresh) > limit {
return 0, port.ErrPlaylistLimit
}
slices.Reverse(fresh)
to.items = append(to.items, fresh...)
to.updated = time.Now()
return len(fresh), nil
}
func (s *Store) MovePlaylistItems(_ context.Context, ownerID, id string, tracks []track.Track, before *track.Track) error {
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
if p == nil {
return port.ErrPlaylistNotFound
}
order := make([]track.Track, len(p.items))
for i, item := range p.items {
order[len(order)-1-i] = item.Track
}
moved, err := playlist.Move(order, tracks, before)
if err != nil || slices.Equal(moved, order) {
return err
}
byTrack := make(map[track.Track]port.PlaylistItem, len(p.items))
for _, item := range p.items {
byTrack[item.Track] = item
}
for i, t := range moved {
p.items[len(moved)-1-i] = byTrack[t]
}
p.updated = time.Now()
return nil
}
+15
View File
@@ -0,0 +1,15 @@
package porttest
import "testing"
func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
if err != nil {
t.Fatal(err)
}
bad := "a\x00@example.com"
if _, err := store.UpdateMember(t.Context(), m.ID, nil, nil, &bad); err == nil {
t.Error("含 NUL 的邮箱应报错")
}
}
+6
View File
@@ -13,3 +13,9 @@ type RoomQuota interface {
// 与 TakeSearch 相同,取的是点歌额度。
TakePick(ctx context.Context, roomID string, burst int, refill time.Duration) (time.Duration, error)
}
// 注册、登录、修改密码的额度,按调用方拼出的键计数,所有实例共用。
type AccountQuota interface {
// 与 RoomQuota.TakeSearch 相同:取到时返回 0,额度用完时返回还要等待的时长。
Take(ctx context.Context, key string, burst int, refill time.Duration) (time.Duration, error)
}
+8 -2
View File
@@ -2,8 +2,14 @@ package port
import "context"
// 没有设置过品牌名时,BrandName 返回空字符串。
type SiteSettings struct {
// 没有设置过品牌名时为空字符串。
BrandName string
RegistrationOpen bool
}
type Site interface {
BrandName(ctx context.Context) (string, error)
Settings(ctx context.Context) (SiteSettings, error)
SetBrandName(ctx context.Context, name string) error
SetRegistrationOpen(ctx context.Context, open bool) error
}
+5 -1
View File
@@ -18,6 +18,8 @@ type NewTicket struct {
MemberID string
// 签发前校验密码时依据的密码哈希,空字符串表示房间没有密码。
PasswordHash string
// 发出请求的会话的 token 哈希:会话删除时票据随之失效。
SessionTokenHash []byte
}
type TicketHolder struct {
@@ -26,11 +28,13 @@ type TicketHolder struct {
Member member.Member
// 票据是常驻房间的 API Key,持有者是这个房间的 bot 成员。
Bot bool
// 票据所属的会话的 token 哈希,API Key 没有会话,为 nil。
SessionTokenHash []byte
}
type Tickets interface {
// 房间的密码哈希已经不是 t.PasswordHash 时返回 ErrPasswordChanged:密码在校验之后被修改过,
// 设置或修改密码时的作废已经执行,此时签发的票据不会再被作废。
// 设置或修改密码时的作废已经执行,此时签发的票据不会再被作废。t.SessionTokenHash 的会话已经不存在时返回 ErrSessionNotFound。
CreateTicket(ctx context.Context, t NewTicket) error
TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolder, error)
}
+1 -1
View File
@@ -398,7 +398,7 @@ func TestGatewayRejoinsAfterItsAliveKeyExpired(t *testing.T) {
if _, err := a.gateway.Do(t.Context(), id, alice, room.Sync{}); err != nil {
t.Fatal(err)
}
s, err := g.Join(t.Context(), id, bob)
s, err := g.Join(t.Context(), id, bob, nil)
if err != nil {
t.Fatal(err)
}
+45 -2
View File
@@ -105,12 +105,12 @@ func New(snapshots port.RoomSnapshots, directory port.RoomDirectory, music *cata
}
// 会话结束时必须调用 Leave。
func (g *Gateway) Join(ctx context.Context, roomID string, m member.Member) (*Session, error) {
func (g *Gateway) Join(ctx context.Context, roomID string, m member.Member, memberSession []byte) (*Session, error) {
link, err := g.hold(ctx, roomID)
if err != nil {
return nil, err
}
s := newSession(room.SessionID(g.nextID("s")), roomID, m)
s := newSession(room.SessionID(g.nextID("s")), roomID, m, memberSession)
s.link = link
if !g.register(s) {
g.unhold(roomID, link, false)
@@ -208,6 +208,19 @@ func (g *Gateway) RevokeAPIKey(ctx context.Context, roomID string) error {
return g.announce(ctx, roomID, port.APIKeyReset{})
}
// RevokeSessions 在成员会话删除之后通知各实例,以 CloseRevoked 关闭用这些会话的票据建立的会话和事件流。
func (g *Gateway) RevokeSessions(ctx context.Context, memberSessions [][]byte) error {
if len(memberSessions) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(ctx, clusterTimeout)
defer cancel()
if err := g.bus.PublishSessionsRevoked(ctx, memberSessions); err != nil {
return unavailable(err)
}
return nil
}
func (g *Gateway) announce(ctx context.Context, roomID string, m port.RoomMessage) error {
ctx, cancel := context.WithTimeout(ctx, clusterTimeout)
defer cancel()
@@ -470,6 +483,34 @@ func (g *Gateway) memberChanged(m member.Member) {
}()
}
// 在接收消息的 goroutine 上调用,不能阻塞。
func (g *Gateway) sessionsRevoked(memberSessions [][]byte) {
revoked := map[string]bool{}
for _, h := range memberSessions {
revoked[string(h)] = true
}
g.mu.Lock()
var closing []*sendQueue
for _, set := range g.sessions {
for s := range set {
if revoked[s.memberSession] {
closing = append(closing, s.sendQueue)
}
}
}
for _, set := range g.observers {
for _, o := range set {
if revoked[o.memberSession] {
closing = append(closing, o.sendQueue)
}
}
}
g.mu.Unlock()
for _, q := range closing {
q.close(CloseRevoked)
}
}
// owner 靠 Join、Leave 和 MemberUpdated 知道谁在线,这几条消息丢失后没有别的办法补回:离开的会话会一直留在在线列表中,
// 临时房间也因此无法关闭;仍然在线的会话则不在列表中。所以送不到 owner 时要一直重试,直到送达。
// next 在每次尝试前调用,返回这一次要发的输入;返回 false 表示这条消息已经不必再发。
@@ -628,6 +669,8 @@ func (g *Gateway) receive(ctx context.Context, lost chan<- struct{}) {
g.host.deliver(d.Context, d.Input)
case port.MemberDelivery:
g.memberChanged(d.Member)
case port.SessionsRevokedDelivery:
g.sessionsRevoked(d.SessionTokenHashes)
case port.PreparedDelivery:
g.host.preparer.Finished(d.Outcome)
case port.SubscriptionLost:
+7 -7
View File
@@ -57,7 +57,7 @@ func startLoggingInstance(t *testing.T, redis *porttest.Redis, id string, snapsh
func (in *instance) join(t *testing.T, roomID string, m member.Member) *Session {
t.Helper()
s, err := in.gateway.Join(t.Context(), roomID, m)
s, err := in.gateway.Join(t.Context(), roomID, m, nil)
if err != nil {
t.Fatalf("%s 加入房间:%v", m.Name, err)
}
@@ -366,7 +366,7 @@ func TestShutdownClosesSessions(t *testing.T) {
store := porttest.New()
f := newGatewayFixture(t, store, store)
id := createRoom(t, store, room.Persistent)
a, err := f.gateway.Join(t.Context(), id, alice)
a, err := f.gateway.Join(t.Context(), id, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -384,7 +384,7 @@ func TestShutdownClosesSessions(t *testing.T) {
if err := <-done; err != nil {
t.Fatal(err)
}
if _, err := f.gateway.Join(t.Context(), id, bob); !errors.Is(err, ErrUnavailable) {
if _, err := f.gateway.Join(t.Context(), id, bob, nil); !errors.Is(err, ErrUnavailable) {
t.Errorf("Shutdown 之后加入的错误 = %v,期望 ErrUnavailable", err)
}
})
@@ -463,7 +463,7 @@ func TestUnsubscribeAfterLastUse(t *testing.T) {
sessionOnly := createRoom(t, store, room.Persistent)
withHTTP := createRoom(t, store, room.Persistent)
s, err := f.gateway.Join(t.Context(), sessionOnly, alice)
s, err := f.gateway.Join(t.Context(), sessionOnly, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -481,7 +481,7 @@ func TestUnsubscribeAfterLastUse(t *testing.T) {
t.Fatal(err)
}
time.Sleep(30 * time.Second)
s, err = f.gateway.Join(t.Context(), withHTTP, alice)
s, err = f.gateway.Join(t.Context(), withHTTP, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -620,7 +620,7 @@ func TestConfirmedSubscriptionIsNotRepeated(t *testing.T) {
store := porttest.New()
g, node, bus := startCountingGateway(t, store)
id := createRoom(t, store, room.Persistent)
s, err := g.Join(t.Context(), id, alice)
s, err := g.Join(t.Context(), id, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -657,7 +657,7 @@ func TestConfirmationAcrossDisconnectIsNotReused(t *testing.T) {
store := porttest.New()
g, node, bus := startCountingGateway(t, store)
id := createRoom(t, store, room.Persistent)
s, err := g.Join(t.Context(), id, alice)
s, err := g.Join(t.Context(), id, alice, nil)
if err != nil {
t.Fatal(err)
}
+11 -9
View File
@@ -17,26 +17,28 @@ var ErrTooManyObservers = errors.New("这个房间的事件流超过 5 条,请
// Observer 是房间的事件流:先收到房间的快照,之后是房间的事件;房间开始新的一次运行时事件重新编号,再收到一份新的快照。
type Observer struct {
*sendQueue
room string
link *roomLink
member member.Member
bot bool
credential string
room string
link *roomLink
member member.Member
// 凭证签发给的成员会话的 token 哈希,API Key 没有会话,为空。
memberSession string
bot bool
credential string
// 由 sendQueue 的 mu 保护:最近一次快照的任期,和收到过的 owner 变更通知中最大的任期。
term, latest uint32
}
// Observe 订阅房间的事件但不加入房间:事件流不出现在在线列表中,不计入投票切歌所需的人数,也不让 AutoDJ 认为房间里有人。
// m 是凭证的持有者,bot 表示凭证是常驻房间的 API Key,credential 是凭证的哈希。名额用完时,同一凭证打开的最早的事件流以
// CloseReplaced 关闭,名额让给这一条;没有同一凭证的事件流时返回 ErrTooManyObservers。
// m 是凭证的持有者,memberSession 是凭证签发给的成员会话,bot 表示凭证是常驻房间的 API Key,credential 是凭证的哈希。
// 名额用完时,同一凭证打开的最早的事件流以 CloseReplaced 关闭,名额让给这一条;没有同一凭证的事件流时返回 ErrTooManyObservers。
// 返回时快照已经放进队列。结束时必须调用一次 Unobserve。
func (g *Gateway) Observe(ctx context.Context, roomID string, m member.Member, bot bool, credential string) (*Observer, error) {
func (g *Gateway) Observe(ctx context.Context, roomID string, m member.Member, memberSession []byte, bot bool, credential string) (*Observer, error) {
link, err := g.hold(ctx, roomID)
if err != nil {
return nil, err
}
o := &Observer{sendQueue: newSendQueue(), room: roomID, link: link, member: m, bot: bot, credential: credential}
o := &Observer{sendQueue: newSendQueue(), room: roomID, link: link, member: m, memberSession: string(memberSession), bot: bot, credential: credential}
if err := g.watch(o); err != nil {
g.unhold(roomID, link, false)
return nil, err
+59 -6
View File
@@ -25,7 +25,7 @@ var (
// 只能在 synctest 的 bubble 中调用。
func (in *instance) observe(t *testing.T, roomID string, m member.Member, bot bool, credential string) *Observer {
t.Helper()
o, err := in.gateway.Observe(t.Context(), roomID, m, bot, credential)
o, err := in.gateway.Observe(t.Context(), roomID, m, nil, bot, credential)
if err != nil {
t.Fatalf("%s 打开事件流:%v", m.Name, err)
}
@@ -126,6 +126,59 @@ func TestAPIKeyResetClosesOnlyBotObservers(t *testing.T) {
})
}
func TestRevokedSessionsCloseOnlyTheirConnections(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
f := newClusterFixture(t, store, store)
lounge := createRoom(t, store, room.Persistent)
join := func(in *instance, roomID string, m member.Member, memberSession string) *Session {
t.Helper()
s, err := in.gateway.Join(t.Context(), roomID, m, []byte(memberSession))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { in.gateway.Leave(s) })
return s
}
observe := func(in *instance, m member.Member, memberSession []byte, bot bool, credential string) *Observer {
t.Helper()
o, err := in.gateway.Observe(t.Context(), f.room, m, memberSession, bot, credential)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { in.gateway.Unobserve(o) })
return o
}
revokedOnA, revokedOnB, kept := join(f.a, f.room, alice, "alice-1"), join(f.b, lounge, alice, "alice-1"), join(f.b, f.room, alice, "alice-2")
other := join(f.b, f.room, bob, "bob-1")
revokedStream, keptStream := observe(f.b, alice, []byte("alice-1"), false, "t1"), observe(f.b, alice, []byte("alice-2"), false, "t2")
bot := observe(f.b, robot, nil, true, "key")
for _, q := range []interface {
Next(context.Context) (Message, CloseReason, error)
}{revokedOnA, revokedOnB, kept, other, revokedStream, keptStream, bot} {
received(t, q)
}
if err := f.a.gateway.RevokeSessions(t.Context(), [][]byte{[]byte("alice-1"), []byte("carol-1")}); err != nil {
t.Fatal(err)
}
revoked := fmt.Sprintf("closed %d", CloseRevoked)
checkReceived(t, revokedOnA, revoked)
checkReceived(t, revokedOnB, revoked)
checkObserved(t, revokedStream, revoked)
for _, s := range []*Session{kept, other} {
if got := received(t, s); slices.Contains(got, revoked) {
t.Errorf("%s 的会话也被关闭了:%v", s.Member().Name, got)
}
}
for _, o := range []*Observer{keptStream, bot} {
if got := received(t, o); slices.Contains(got, revoked) {
t.Errorf("%s 的事件流也被关闭了:%v", o.member.Name, got)
}
}
})
}
func TestObserverGetsNewSnapshotWhenRunChanges(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := &failingStore{Store: porttest.New()}
@@ -203,7 +256,7 @@ func TestObserverSkipsSnapshotOfEarlierRun(t *testing.T) {
opened := make(chan error, 1)
go func() {
var err error
o, err = b.gateway.Observe(context.Background(), id, robot, true, "key")
o, err = b.gateway.Observe(context.Background(), id, robot, nil, true, "key")
opened <- err
}()
synctest.Wait()
@@ -225,14 +278,14 @@ func TestObserverLimitPerRoom(t *testing.T) {
store := porttest.New()
f := newGatewayFixture(t, store, store)
full, other := createRoom(t, store, room.Persistent), createRoom(t, store, room.Persistent)
first, err := f.gateway.Observe(t.Context(), full, robot, true, "key")
first, err := f.gateway.Observe(t.Context(), full, robot, nil, true, "key")
if err != nil {
t.Fatal(err)
}
for range observerLimit - 1 {
f.observe(t, full, robot, true, "key")
}
if _, err := f.gateway.Observe(t.Context(), full, alice, false, "alice"); !errors.Is(err, ErrTooManyObservers) {
if _, err := f.gateway.Observe(t.Context(), full, alice, nil, false, "alice"); !errors.Is(err, ErrTooManyObservers) {
t.Errorf("第 %d 个事件流的错误 = %v,期望 ErrTooManyObservers", observerLimit+1, err)
}
f.observe(t, other, alice, false, "alice")
@@ -258,7 +311,7 @@ func TestFullRoomReplacesOldestObserverOfSameCredential(t *testing.T) {
checkObserved(t, o, "snapshot 1/0 []")
}
if _, err := f.gateway.Observe(t.Context(), id, bob, false, "bob 的另一张票据"); !errors.Is(err, ErrTooManyObservers) {
if _, err := f.gateway.Observe(t.Context(), id, bob, nil, false, "bob 的另一张票据"); !errors.Is(err, ErrTooManyObservers) {
t.Errorf("名额用完、没有同一凭证的事件流时的错误 = %v,期望 ErrTooManyObservers", err)
}
for _, o := range open {
@@ -273,7 +326,7 @@ func TestFailedObserveFreesItsSlot(t *testing.T) {
f := newGatewayFixture(t, store, store)
missing := "01a15778-1911-7a28-8b2e-e08c2a25f388"
for i := range observerLimit + 1 {
if _, err := f.gateway.Observe(t.Context(), missing, robot, true, fmt.Sprint(i)); !errors.Is(err, port.ErrRoomNotFound) {
if _, err := f.gateway.Observe(t.Context(), missing, robot, nil, true, fmt.Sprint(i)); !errors.Is(err, port.ErrRoomNotFound) {
t.Fatalf("第 %d 次打开不存在的房间的事件流 = %v,期望 ErrRoomNotFound", i+1, err)
}
}
+4 -2
View File
@@ -83,6 +83,8 @@ type Session struct {
id room.SessionID
room string
link *roomLink
// 连接所用的票据签发给的成员会话的 token 哈希。
memberSession string
// 在发出 Leave 之前设置。重新加入的 Join 可能排在 Leave 之后,网关据此补发 Leave。
left atomic.Bool
@@ -91,8 +93,8 @@ type Session struct {
outstanding int
}
func newSession(id room.SessionID, roomID string, m member.Member) *Session {
return &Session{sendQueue: newSendQueue(), id: id, room: roomID, member: m}
func newSession(id room.SessionID, roomID string, m member.Member, memberSession []byte) *Session {
return &Session{sendQueue: newSendQueue(), id: id, room: roomID, member: m, memberSession: string(memberSession)}
}
func (s *Session) ended() bool {
+15 -5
View File
@@ -25,15 +25,25 @@ func New(site port.Site) *Service {
return &Service{site: site}
}
func (s *Service) BrandName(ctx context.Context) (string, error) {
name, err := s.site.BrandName(ctx)
type Settings struct {
BrandName string
RegistrationOpen bool
}
func (s *Service) Settings(ctx context.Context) (Settings, error) {
settings, err := s.site.Settings(ctx)
if err != nil {
return "", err
return Settings{}, err
}
name := settings.BrandName
if name == "" {
return defaultBrandName, nil
name = defaultBrandName
}
return name, nil
return Settings{BrandName: name, RegistrationOpen: settings.RegistrationOpen}, nil
}
func (s *Service) SetRegistrationOpen(ctx context.Context, open bool) error {
return s.site.SetRegistrationOpen(ctx, open)
}
func (s *Service) SetBrandName(ctx context.Context, name string) (string, error) {
+15 -12
View File
@@ -10,16 +10,19 @@ import (
"github.com/dihemoe/alisten/internal/app/port/porttest"
)
func TestBrandName(t *testing.T) {
func TestSettings(t *testing.T) {
s := New(porttest.New())
if got, err := s.BrandName(t.Context()); err != nil || got != "Alisten" {
t.Fatalf("没有设置过时 = %q, %v,期望默认的 Alisten", got, err)
if got, err := s.Settings(t.Context()); err != nil || got != (Settings{BrandName: "Alisten", RegistrationOpen: true}) {
t.Fatalf("没有设置过时 = %#v, %v,期望默认的 Alisten、开放注册", got, err)
}
if saved, err := s.SetBrandName(t.Context(), " 周末听歌会\n"); err != nil || saved != "周末听歌会" {
t.Fatalf("SetBrandName = %q, %v,期望返回去掉首尾空白的 %q", saved, err, "周末听歌会")
}
if got, err := s.BrandName(t.Context()); err != nil || got != "周末听歌会" {
t.Errorf("设置之后 = %q, %v,期望去掉首尾空白的 %q", got, err, "周末听歌会")
if err := s.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
if got, err := s.Settings(t.Context()); err != nil || got != (Settings{BrandName: "周末听歌会", RegistrationOpen: false}) {
t.Errorf("设置之后 = %#v, %v", got, err)
}
}
@@ -42,8 +45,8 @@ func TestInvalidBrandName(t *testing.T) {
t.Errorf("SetBrandName(%q) = %v,期望 ErrInvalidBrandName", name, err)
}
}
if got, err := s.BrandName(t.Context()); err != nil || got != "周末听歌会" {
t.Errorf("被拒绝之后 = %q, %v,期望仍是 %q", got, err, "周末听歌会")
if got, err := s.Settings(t.Context()); err != nil || got.BrandName != "周末听歌会" {
t.Errorf("被拒绝之后 = %q, %v,期望仍是 %q", got.BrandName, err, "周末听歌会")
}
}
@@ -63,12 +66,12 @@ type brokenSite struct{ port.Site }
var errBroken = errors.New("连接中断")
func (brokenSite) BrandName(context.Context) (string, error) {
return "", errBroken
func (brokenSite) Settings(context.Context) (port.SiteSettings, error) {
return port.SiteSettings{}, errBroken
}
func TestBrandNameReadFailure(t *testing.T) {
if got, err := New(brokenSite{}).BrandName(t.Context()); !errors.Is(err, errBroken) {
t.Errorf("读取失败时 = %q, %v,期望返回错误而不是默认的品牌名", got, err)
func TestSettingsReadFailure(t *testing.T) {
if got, err := New(brokenSite{}).Settings(t.Context()); !errors.Is(err, errBroken) {
t.Errorf("读取失败时 = %#v, %v,期望返回错误而不是默认的品牌名", got, err)
}
}
+45 -2
View File
@@ -30,8 +30,9 @@ type serveConfig struct {
redis *goredis.Options
storage storage.Config
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
storageURL string
tracing bool
storageURL string
trustedProxies []netip.Prefix
tracing bool
}
func loadServeConfig() (serveConfig, error) {
@@ -57,6 +58,10 @@ func loadServeConfig() (serveConfig, error) {
return serveConfig{}, err
}
adminToken := os.Getenv("ALISTEN_ADMIN_TOKEN")
trustedProxies, err := parseTrustedProxies(os.Getenv("ALISTEN_TRUSTED_PROXIES"))
if err != nil {
return serveConfig{}, err
}
database, err := loadDatabaseConfig()
if err != nil {
return serveConfig{}, err
@@ -91,6 +96,7 @@ func loadServeConfig() (serveConfig, error) {
redis: redis,
storage: objects,
storageURL: storageURL,
trustedProxies: trustedProxies,
tracing: tracing,
}, nil
}
@@ -139,6 +145,43 @@ func parseAllowedOrigins(value, fallback string) ([]string, error) {
return origins, nil
}
func parseTrustedProxies(value string) ([]netip.Prefix, error) {
if value == "" {
return nil, nil
}
var prefixes []netip.Prefix
for raw := range strings.SplitSeq(value, ",") {
item := strings.TrimSpace(raw)
if item == "" {
return nil, errors.New("ALISTEN_TRUSTED_PROXIES 中有空项")
}
prefix, err := parseTrustedProxy(item)
if err != nil {
return nil, fmt.Errorf("ALISTEN_TRUSTED_PROXIES 中的 %q 不是 IP 地址或 CIDR:%w", item, err)
}
prefixes = append(prefixes, prefix)
}
return prefixes, nil
}
// 单个地址视为只含它自己的网段。clientIP 先把 IPv4 映射的 IPv6 地址还原成 IPv4 再匹配,所以 ::ffff: 开头的网段换成等价的
// IPv4 网段,否则永远匹配不上。带 zone 的地址永远匹配不上网段,按不合法处理。
func parseTrustedProxy(item string) (netip.Prefix, error) {
prefix, err := netip.ParsePrefix(item)
if err != nil {
addr, addrErr := netip.ParseAddr(item)
if addrErr != nil || addr.Zone() != "" {
return netip.Prefix{}, err
}
prefix = netip.PrefixFrom(addr, addr.BitLen())
}
prefix = prefix.Masked()
if prefix.Addr().Is4In6() {
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
}
return prefix, nil
}
func canonicalPort(port string) bool {
n, err := strconv.Atoi(port)
return err == nil && n >= 1 && n <= 65535 && strconv.Itoa(n) == port
+35
View File
@@ -2,7 +2,9 @@ package bootstrap
import (
"log/slog"
"net/netip"
"os"
"slices"
"testing"
)
@@ -83,3 +85,36 @@ func TestLoadLogLevel(t *testing.T) {
}
func ptr(s string) *string { return &s }
func TestParseTrustedProxies(t *testing.T) {
got, err := parseTrustedProxies("10.42.0.0/16, 127.0.0.1/32,::1/128,10.43.1.2/16")
want := []netip.Prefix{
netip.MustParsePrefix("10.42.0.0/16"),
netip.MustParsePrefix("127.0.0.1/32"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("10.43.0.0/16"),
}
if err != nil || !slices.Equal(got, want) {
t.Errorf("parseTrustedProxies = %v, %v,期望 %v", got, err, want)
}
if got, err := parseTrustedProxies(""); err != nil || got != nil {
t.Errorf("空值 = %v, %v,期望没有受信任的代理", got, err)
}
// 单个地址只含它自己;客户端地址先还原成 IPv4 再匹配,所以 IPv4 映射的网段换成等价的 IPv4 网段。
for value, want := range map[string]string{
"10.42.0.1": "10.42.0.1/32",
"fd00::1": "fd00::1/128",
"::ffff:10.42.0.0/112": "10.42.0.0/16",
"::ffff:10.42.0.1": "10.42.0.1/32",
"::ffff:0.0.0.0/96": "0.0.0.0/0",
} {
if got, err := parseTrustedProxies(value); err != nil || !slices.Equal(got, []netip.Prefix{netip.MustParsePrefix(want)}) {
t.Errorf("parseTrustedProxies(%q) = %v, %v,期望 %s", value, got, err, want)
}
}
for _, bad := range []string{"10.42.0.0/16,", "not-a-cidr", "10.42.0.1/33", "fe80::1%eth0", "10.42.0.256"} {
if _, err := parseTrustedProxies(bad); err == nil {
t.Errorf("parseTrustedProxies(%q) 应报错", bad)
}
}
}
+1
View File
@@ -344,6 +344,7 @@ func contractEnv(t *testing.T, databaseURL, redisURL string) {
t.Setenv("ALISTEN_PUBLIC_URL", "https://alisten.example")
t.Setenv("ALISTEN_ALLOWED_ORIGINS", "https://alisten.example,http://localhost:9876")
t.Setenv("ALISTEN_ADMIN_TOKEN", "admin-secret")
t.Setenv("ALISTEN_TRUSTED_PROXIES", "")
t.Setenv("STORAGE_PUBLIC_URL", "https://alisten.example/storage/alisten")
}
+97
View File
@@ -1,9 +1,11 @@
package bootstrap
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"slices"
"strings"
"testing"
"time"
@@ -155,3 +157,98 @@ func TestRestoredLinksNeedTheirAudio(t *testing.T) {
t.Errorf("音频还在的链接有 %d 个准备任务,期望没有", n)
}
}
// 准备好的链接曲目进了歌单之后,三处封面地址都由对象存储的公开地址和封面的对象键拼出;换一种大小写写法也能找到它。
func TestPlaylistLinkTracks(t *testing.T) {
contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
svc := startService(t)
dbConfig, err := loadDatabaseConfig()
if err != nil {
t.Fatal(err)
}
pool, err := pgxpool.NewWithConfig(t.Context(), dbConfig)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
const link = "https://example.com/Song.mp3"
cover := track.ObjectKey(media.CoverObject(media.Key(link), "jpg"))
prepared := port.PreparedTrack{
Meta: track.Meta{Title: "Song.mp3", CoverKey: cover, PageURL: link, Duration: 2 * time.Second},
Result: track.Prepared{Duration: 2 * time.Second, Gain: 1},
MediaKey: media.Key(link),
MediaSize: 3,
}
if err := postgres.New(pool).SavePreparedTrack(t.Context(), track.Track{Source: track.Link, ID: link}, prepared); err != nil {
t.Fatal(err)
}
coverURL := "https://alisten.example/storage/alisten/" + string(cover)
call := func(request string, status int, resp any) {
t.Helper()
line, got, _, body := send(t, svc.server, request)
if got != status {
t.Fatalf("%s = %d %s,期望 %d", line, got, body, status)
}
if resp != nil {
if err := json.Unmarshal(body, resp); err != nil {
t.Fatalf("%s 的响应体 %s:%v", line, body, err)
}
}
}
var session struct {
MemberToken string `json:"memberToken"`
}
call("POST /api/members\n\n{}", http.StatusCreated, &session)
auth := "\nAuthorization: Bearer " + session.MemberToken
call("POST /api/account"+auth+"\n\n"+`{"username": "alice", "password": "correct horse"}`, http.StatusCreated, nil)
type item struct {
Track struct {
ID string `json:"id"`
} `json:"track"`
Meta struct {
CoverURL string `json:"coverUrl"`
} `json:"meta"`
}
var lists struct {
Items []struct {
ID string `json:"id"`
CoverURL *string `json:"coverUrl"`
} `json:"items"`
}
call("GET /api/me/playlists"+auth, http.StatusOK, &lists)
favorites := lists.Items[0].ID
var added struct {
Item item `json:"item"`
}
call("POST /api/me/playlists/"+favorites+"/items"+auth+"\n\n"+`{"track": {"source": "link", "id": "HTTPS://EXAMPLE.COM/Song.mp3"}}`, http.StatusCreated, &added)
if added.Item.Track.ID != link || added.Item.Meta.CoverURL != coverURL {
t.Errorf("加入的曲目 = %+v,期望 ID %s、封面 %s", added.Item, link, coverURL)
}
call("GET /api/me/playlists"+auth, http.StatusOK, &lists)
if got := lists.Items[0].CoverURL; got == nil || *got != coverURL {
t.Errorf("歌单的封面 = %v,期望 %s", got, coverURL)
}
var items struct {
Items []item `json:"items"`
}
call("GET /api/me/playlists/"+favorites+"/items"+auth, http.StatusOK, &items)
if len(items.Items) != 1 || items.Items[0].Track.ID != link || items.Items[0].Meta.CoverURL != coverURL {
t.Errorf("歌单里的曲目 = %+v,期望一首 ID %s、封面 %s", items.Items, link, coverURL)
}
var memberships struct {
PlaylistIDs []string `json:"playlistIds"`
}
call("POST /api/me/playlist-memberships"+auth+"\n\n"+`{"track": {"source": "link", "id": "https://Example.com/Song.mp3"}}`, http.StatusOK, &memberships)
if !slices.Equal(memberships.PlaylistIDs, []string{favorites}) {
t.Errorf("换一种大小写查所在的歌单 = %v,期望 [%s]", memberships.PlaylistIDs, favorites)
}
call("POST /api/me/playlists/"+favorites+"/removals"+auth+"\n\n"+`{"tracks": [{"source": "link", "id": "hTTps://example.COM/Song.mp3"}]}`, http.StatusNoContent, nil)
call("GET /api/me/playlists/"+favorites+"/items"+auth, http.StatusOK, &items)
if len(items.Items) != 0 {
t.Errorf("换一种大小写移出之后还有 %+v,期望没有", items.Items)
}
}
+13 -9
View File
@@ -28,6 +28,7 @@ import (
"github.com/dihemoe/alisten/internal/app/identity"
"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/prepare"
"github.com/dihemoe/alisten/internal/app/realtime"
@@ -77,16 +78,19 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
registerRuntimeMetrics(metrics, host, gateway)
rooms := lobby.New(store, leases)
services := api.Services{
Identity: identity.New(store, store, store, gateway),
Lobby: rooms,
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
Gateway: gateway,
Catalog: music,
Media: media.NewLibrary(store, store, objects),
Playback: media.NewPlayback(src.playback, store),
Site: site.New(store),
Identity: identity.New(store, store, store, gateway),
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), gateway, base.With("component", "accounts")),
Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb)),
Lobby: rooms,
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
Gateway: gateway,
Catalog: music,
Media: media.NewLibrary(store, store, objects),
Playback: media.NewPlayback(src.playback, store),
Site: site.New(store),
StorageURL: cfg.storageURL,
StorageURL: cfg.storageURL,
TrustedProxies: cfg.trustedProxies,
}
httpLogger := base.With("component", "http")
return public{
+132
View File
@@ -0,0 +1,132 @@
登录按客户端 IP 限流:同一个 IP 1 分钟内最多尝试 10 次,换一个用户名也算在内;请求经受信任的代理转发时,按 X-Forwarded-For 中的客户端地址计数,另一个客户端不受影响。
-- env --
ALISTEN_TRUSTED_PROXIES=127.0.0.0/8,::1/128
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$guest", "member": {"id": "$guestId", "name": "$guestName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user01", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user02", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user03", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user04", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user05", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user06", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user07", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user08", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user09", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user10", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user11", "password": "wrong horse"}
-- response --
429
{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.8
{"username": "user12", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
+258
View File
@@ -0,0 +1,258 @@
账号:游客注册之后成员和成员 token 都不变;其他游客用账号登录,得到新的成员 token,自己原来的 token 随即失效;修改密码、退出登录让对应的成员 token 失效;后台可以查找账号、重置密码。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice"}}
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
PATCH /api/me
Authorization: Bearer $alice
{"email": " Alice@Example.COM "}
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice2", "password": "correct horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "已经注册过了"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
PATCH /api/me
Authorization: Bearer $bob
{"email": "Alice@Example.COM"}
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
PATCH /api/me
Authorization: Bearer $bob
{"email": ""}
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "alice", "password": "another horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "用户名已被占用"}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "Bob", "password": "another horse"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "username:格式不正确"}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "bob", "password": "short"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "password:长度不能少于 8 个字符"}}
-- request --
POST /api/sessions
Authorization: Bearer $bob
{"username": "alice", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $bob
{"username": "alice", "password": "correct horse"}
-- response --
201
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
-- request --
GET /api/me
Authorization: Bearer $alice2
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
-- request --
PATCH /api/me
Authorization: Bearer $alice2
{"email": ""}
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
POST /api/sessions
Authorization: Bearer $alice2
{"username": "alice", "password": "correct horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "已经登录了账号,先退出再登录其他账号"}}
-- request --
PUT /api/account/password
Authorization: Bearer $alice
{"currentPassword": "wrong horse", "newPassword": "battery staple"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "当前密码错误"}}
-- request --
PUT /api/account/password
Authorization: Bearer $alice
{"currentPassword": "correct horse", "newPassword": "battery staple"}
-- response --
204
-- request --
GET /api/me
Authorization: Bearer $alice2
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
DELETE /api/sessions/current
Authorization: Bearer $alice
-- response --
204
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
GET /api/admin/accounts/alice
Authorization: Bearer admin-secret
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "username": "alice"}
-- request --
GET /api/admin/accounts/nobody
Authorization: Bearer admin-secret
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "没有这个用户名的账号"}}
-- request --
GET /api/admin/accounts/Not%20Valid
Authorization: Bearer admin-secret
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "没有这个用户名的账号"}}
-- request --
POST /api/admin/accounts/alice/password
Authorization: Bearer admin-secret
{}
-- response --
200
{"password": "$resetPassword"}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$dave", "member": {"id": "$daveId", "name": "$daveName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $dave
{"username": "alice", "password": "$resetPassword"}
-- response --
201
{"memberToken": "$alice4", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
+1 -1
View File
@@ -107,7 +107,7 @@ Location: /api/admin/rooms/$hall/api-key
{"key": "$resetKey"}
-- event bot --
event: close
data: {"code": 4401, "message": "凭证已失效:房间设置或修改了密码,或者 API Key 已重置"}
data: {"code": 4401, "message": "凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"}
-- request --
POST /api/room/messages
+380
View File
@@ -0,0 +1,380 @@
歌单:只有已注册的账号能使用;注册时自带不能重命名、不能删除的“我喜欢”;收藏时元数据由服务端查询,同一首歌只收一次,新加入的在前;可以批量移出、复制到别的歌单、调整顺序;别人的歌单和不存在的一样。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
-- request --
POST /api/me/playlists
Authorization: Bearer $alice
{"name": "通勤"}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice"}}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "186016"}}
-- response --
201
{"item": {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "186016"}}
-- response --
201
{"item": {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "185811"}}
-- response --
201
{"item": {"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}}
-- request --
GET /api/me/playlists/$favorites/items
Authorization: Bearer $alice
-- response --
200
{"items": [
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
]}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "link", "id": "https://example.com/song.mp3"}}
-- response --
409
{"error": {"code": "CONFLICT", "message": "这首歌还在准备,准备好之后再保存"}}
-- request --
POST /api/me/playlists
Authorization: Bearer $alice
{"name": " 通勤 "}
-- response --
201
{"id": "$commute", "name": "通勤", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}
-- request --
POST /api/me/playlists
Authorization: Bearer $alice
{"name": " "}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "name:去掉首尾空白后应有 1 到 50 个字符,其中至少一个看得见,不能包含控制字符"}}
-- request --
PATCH /api/me/playlists/$commute
Authorization: Bearer $alice
{"name": "上班路上"}
-- response --
204
-- request --
PATCH /api/me/playlists/$favorites
Authorization: Bearer $alice
{"name": "别的名字"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "“我喜欢”不能重命名或删除"}}
-- request --
DELETE /api/me/playlists/$favorites
Authorization: Bearer $alice
-- response --
409
{"error": {"code": "CONFLICT", "message": "“我喜欢”不能重命名或删除"}}
-- request --
POST /api/me/playlists/$commute/copies
Authorization: Bearer $alice
{"from": "$favorites", "tracks": [{"source": "netease", "id": "186016"}, {"source": "netease", "id": "185811"}, {"source": "netease", "id": "999"}, {"source": "netease", "id": "186016"}]}
-- response --
200
{"added": 2}
-- request --
POST /api/me/playlists/$commute/copies
Authorization: Bearer $alice
{"from": "$favorites", "tracks": [{"source": "netease", "id": "185811"}]}
-- response --
200
{"added": 0}
-- request --
GET /api/me/playlists/$commute/items
Authorization: Bearer $alice
-- response --
200
{"items": [
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}},
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}
]}
-- request --
POST /api/me/playlists/$commute/moves
Authorization: Bearer $alice
{"tracks": [{"source": "netease", "id": "186016"}]}
-- response --
204
-- request --
GET /api/me/playlists/$commute/items
Authorization: Bearer $alice
-- response --
200
{"items": [
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
]}
-- request --
POST /api/me/playlists/$commute/moves
Authorization: Bearer $alice
{"tracks": [{"source": "netease", "id": "186016"}], "before": {"source": "netease", "id": "185811"}}
-- response --
204
-- request --
GET /api/me/playlists/$commute/items
Authorization: Bearer $alice
-- response --
200
{"items": [
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}},
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}
]}
-- request --
POST /api/me/playlists/$commute/moves
Authorization: Bearer $alice
{"tracks": [{"source": "netease", "id": "186016"}], "before": {"source": "netease", "id": "999"}}
-- response --
409
{"error": {"code": "CONFLICT", "message": "歌单已经变了,刷新之后再试"}}
-- request --
POST /api/me/playlists/$commute/moves
Authorization: Bearer $alice
{"tracks": [{"source": "netease", "id": "186016"}], "before": {"source": "netease", "id": "186016"}}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "before 不能是要移动的歌"}}
-- request --
POST /api/me/playlists/$commute/moves
Authorization: Bearer $alice
{"tracks": []}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "tracks:元素不能少于 1 个"}}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}, {"id": "$commute", "name": "上班路上", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}]}
-- request --
POST /api/me/playlist-memberships
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "186016"}}
-- response --
200
{"playlistIds": ["$favorites", "$commute"]}
-- request --
POST /api/me/playlist-memberships
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "999"}}
-- response --
200
{"playlistIds": []}
-- request --
POST /api/me/playlists/$favorites/removals
Authorization: Bearer $alice
{"tracks": [{"source": "netease", "id": "186016"}, {"source": "netease", "id": "999"}]}
-- response --
204
-- request --
POST /api/me/playlists/$favorites/removals
Authorization: Bearer $alice
{"tracks": [{"source": "netease", "id": "186016"}, {"source": "netease", "id": "999"}]}
-- response --
204
-- request --
GET /api/me/playlists/$favorites/items
Authorization: Bearer $alice
-- response --
200
{"items": [{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}]}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "bob", "password": "correct horse"}
-- response --
201
{"account": {"username": "bob"}}
-- request --
GET /api/me/playlists
Authorization: Bearer $bob
-- response --
200
{"items": [{"id": "$bobFavorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]}
-- request --
POST /api/me/playlists/$bobFavorites/copies
Authorization: Bearer $bob
{"from": "$favorites", "tracks": [{"source": "netease", "id": "185811"}]}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
POST /api/me/playlists/$favorites/copies
Authorization: Bearer $bob
{"from": "$bobFavorites", "tracks": [{"source": "netease", "id": "185811"}]}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
POST /api/me/playlists/$favorites/moves
Authorization: Bearer $bob
{"tracks": [{"source": "netease", "id": "185811"}]}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
PATCH /api/me/playlists/$favorites
Authorization: Bearer $bob
{"name": "别人的名字"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $bob
{"track": {"source": "netease", "id": "186016"}}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
POST /api/me/playlists/$favorites/removals
Authorization: Bearer $bob
{"tracks": [{"source": "netease", "id": "185811"}]}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
GET /api/me/playlists/$favorites/items
Authorization: Bearer $bob
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
DELETE /api/me/playlists/$commute
Authorization: Bearer $bob
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
GET /api/me/playlists/not-a-uuid/items
Authorization: Bearer $alice
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
DELETE /api/me/playlists/$commute
Authorization: Bearer $alice
-- response --
204
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 1, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}]}
+9
View File
@@ -36,6 +36,15 @@ Authorization: Bearer $alice
PATCH /api/me
Authorization: Bearer $alice
{"email": "alice@example"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "email:格式不正确,应形如 name@example.com"}}
-- request --
PATCH /api/me
Authorization: Bearer $alice
{}
-- response --
200
+50 -4
View File
@@ -1,10 +1,10 @@
站点:品牌名默认是 Alisten;后台修改时返回存下的名字,首尾的空白已经去掉,之后读到的也是它;空的、超过 20 个字符的和看不见的名字不能保存;修改品牌名要管理 token。
站点:品牌名默认是 Alisten,默认开放注册;后台修改品牌名时返回存下的名字,首尾的空白已经去掉,之后读到的也是它;空的、超过 20 个字符的和看不见的名字不能保存;后台可以关闭和重新开放注册,关闭之后游客不能注册;修改站点设置要管理 token。
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten"}
{"brandName": "Alisten", "registrationOpen": true}
-- request --
PATCH /api/admin/site
@@ -19,7 +19,7 @@ Authorization: Bearer admin-secret
GET /api/site
-- response --
200
{"brandName": "周末听歌会"}
{"brandName": "周末听歌会", "registrationOpen": true}
-- request --
PATCH /api/admin/site
@@ -56,8 +56,54 @@ PATCH /api/admin/site
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
DELETE /api/admin/registration
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
DELETE /api/admin/registration
Authorization: Bearer admin-secret
-- response --
204
-- request --
GET /api/site
-- response --
200
{"brandName": "周末听歌会"}
{"brandName": "周末听歌会", "registrationOpen": false}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$guest", "member": {"id": "$guestId", "name": "$guestName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $guest
{"username": "guest", "password": "correct horse"}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册已关闭"}}
-- request --
PUT /api/admin/registration
Authorization: Bearer admin-secret
{}
-- response --
204
-- request --
POST /api/account
Authorization: Bearer $guest
{"username": "guest", "password": "correct horse"}
-- response --
201
{"account": {"username": "guest"}}
+142
View File
@@ -0,0 +1,142 @@
票据属于签发它的会话:账号在两台设备上各自进入同一个房间,一台设备修改密码之后,另一台设备的 WebSocket 以 4401 断开,
用它的票据打开的事件流收到 4401 的 close;这台设备的旧票据再也不能用来进房或调用房间接口。发出请求的设备不受影响。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$guest", "member": {"id": "$guestId", "name": "$guestName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
{"username": "alice", "password": "correct horse"}
-- response --
201
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
POST /api/rooms
{"name": "临时", "description": ""}
-- response --
201
Location: /api/rooms/$room
{"id": "$room"}
-- request --
POST /api/rooms/$room/tickets
Authorization: Bearer $alice
{}
-- response --
201
{"ticket": "$ticketA"}
-- request --
POST /api/rooms/$room/tickets
Authorization: Bearer $alice2
{}
-- response --
201
{"ticket": "$ticketB"}
-- connect a --
-- send a --
{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketA", "protocolVersion": "$protocolVersion"}}
-- receive a --
{"kind": "response", "type": "hello", "id": "h1", "data": {
"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
"serverTimeMs": 1,
"room": {"term": 1, "seq": 1, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
"playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
"members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
-- connect b --
-- send b --
{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketB", "protocolVersion": "$protocolVersion"}}
-- receive b --
{"kind": "response", "type": "hello", "id": "h1", "data": {
"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
"serverTimeMs": 1,
"room": {"term": 1, "seq": 2, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
"playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
"members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 2}}}}
-- receive a --
{"kind": "event", "type": "members.updated", "term": 1, "seq": 2, "data": {
"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 2}}
-- stream b --
Authorization: Bearer $ticketB
-- event b --
event: snapshot
data: {"room": {"term": 1, "seq": 2, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
"playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
"members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 2}}}
-- request --
PUT /api/account/password
Authorization: Bearer $alice
{"currentPassword": "correct horse", "newPassword": "battery staple"}
-- response --
204
-- closed b --
4401
-- event b --
event: close
data: {"code": 4401, "message": "凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"}
-- receive a --
{"kind": "event", "type": "members.updated", "term": 1, "seq": 3, "data": {
"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}
-- send a --
{"kind": "request", "type": "ping", "id": "p1", "data": {"clientTimeMs": 1234}}
-- receive a --
{"kind": "response", "type": "ping", "id": "p1", "data": {"clientTimeMs": 1234, "serverTimeMs": 1}}
-- connect again --
-- send again --
{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketB", "protocolVersion": "$protocolVersion"}}
-- receive again --
{"kind": "response", "type": "hello", "id": "h1", "data": {}, "error": {"code": "UNAUTHORIZED", "message": "票据无效"}}
-- closed again --
4401
-- request --
GET /api/room
Authorization: Bearer $ticketB
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "票据无效"}}
-- request --
POST /api/room/messages
Authorization: Bearer $ticketA
{"content": "还在"}
-- response --
204
-- receive a --
{"kind": "event", "type": "chat.message", "term": 1, "seq": 4, "data": {"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "content": "还在", "sentAtMs": 1}}
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more