Compare commits
18
Commits
99987a63bf
...
5ca315f226
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ca315f226 | ||
|
|
fb0820fae1 | ||
|
|
0c6007e7c4 | ||
|
|
d6a77bb04b | ||
|
|
24abca6dc7 | ||
|
|
5c34450ccf | ||
|
|
d9a97b6331 | ||
|
|
dd639169f9 | ||
|
|
d103d1278b | ||
|
|
e69dc18581 | ||
|
|
54efdb6dd2 | ||
|
|
eb4202c8ba | ||
|
|
791171d5ab | ||
|
|
b8d9fac4c3 | ||
|
|
cd61d33248 | ||
|
|
1277b3ac85 | ||
|
|
026b7e7f1a | ||
|
|
fa928102d1 |
No files matched your search
@@ -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="" # 进入管理后台需要的凭证, 留空则禁用后台
|
||||
|
||||
@@ -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)", refName(p.Items.Ref))
|
||||
case p.Ref != "":
|
||||
decode = "decode" + refName(p.Ref)
|
||||
case p.Type == "integer":
|
||||
@@ -149,6 +151,12 @@ 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.MaxItems != nil {
|
||||
check("checkMaxItems", fmt.Sprint(*s.MaxItems))
|
||||
}
|
||||
if s.Minimum != nil {
|
||||
check("checkMinimum", fmt.Sprint(*s.Minimum))
|
||||
}
|
||||
|
||||
+33
-3
@@ -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,44 @@ 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.MinItems != nil && *s.MinItems < 0 || s.MaxItems != nil && *s.MaxItems < 0 {
|
||||
return fmt.Errorf("%s:minItems 和 maxItems 不能是负数", where)
|
||||
}
|
||||
if s.MinItems != nil && s.MaxItems != 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 +987,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 +1083,8 @@ func (s *schema) keywords() []string {
|
||||
add(s.Required != nil, "required")
|
||||
add(s.AdditionalProperties != nil, "additionalProperties")
|
||||
add(s.Items != nil, "items")
|
||||
add(s.MinItems != nil, "minItems")
|
||||
add(s.MaxItems != nil, "maxItems")
|
||||
add(s.AnyOf != nil, "anyOf")
|
||||
add(s.OneOf != nil, "oneOf")
|
||||
add(s.AllOf != nil, "allOf")
|
||||
|
||||
@@ -143,17 +143,28 @@ 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:数组必须是必填字段"},
|
||||
{"请求数组的 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{}))}
|
||||
|
||||
@@ -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,27 @@ 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)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 3); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
|
||||
var v RoomSyncRequest
|
||||
_, err := decodeObject(raw, path)
|
||||
|
||||
@@ -212,6 +212,21 @@
|
||||
"required": ["member", "token"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "removeTracks",
|
||||
"method": "POST",
|
||||
"path": "/api/tracks/removals",
|
||||
"auth": "none",
|
||||
"status": 204,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 3}
|
||||
},
|
||||
"required": ["tracks"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getRoom",
|
||||
"method": "GET",
|
||||
|
||||
+49
-1
@@ -183,6 +183,23 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RemoveTracksRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tracks": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Track"
|
||||
},
|
||||
"maxItems": 3,
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tracks"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Room": {
|
||||
"additionalProperties": false,
|
||||
"description": "房间",
|
||||
@@ -318,7 +335,7 @@
|
||||
},
|
||||
"info": {
|
||||
"title": "Sample",
|
||||
"version": "362d144af81ba8bf"
|
||||
"version": "5e495c1e23fa03a5"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -774,6 +791,37 @@
|
||||
],
|
||||
"summary": "座位上的成员,<b>座位号</b> 从 1 开始 & 不超过 8"
|
||||
}
|
||||
},
|
||||
"/api/tracks/removals": {
|
||||
"post": {
|
||||
"operationId": "removeTracks",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RemoveTracksRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -4,7 +4,7 @@ package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const Version = "362d144af81ba8bf"
|
||||
const Version = "5e495c1e23fa03a5"
|
||||
|
||||
type AdminUpdateRoomRequest struct {
|
||||
Homepage *string `json:"homepage,omitempty"`
|
||||
@@ -88,6 +88,18 @@ type QueueRemoveRequest struct {
|
||||
EntryID string `json:"entryId"`
|
||||
}
|
||||
|
||||
type RemoveTracksRequest struct {
|
||||
Tracks []Track `json:"tracks"`
|
||||
}
|
||||
|
||||
func (v RemoveTracksRequest) MarshalJSON() ([]byte, error) {
|
||||
type plain RemoveTracksRequest
|
||||
if v.Tracks == nil {
|
||||
v.Tracks = []Track{}
|
||||
}
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
Gain float64 `json:"gain"`
|
||||
ID string `json:"id"`
|
||||
@@ -154,6 +166,7 @@ const (
|
||||
|
||||
var (
|
||||
RouteCreateMember = Route{Pattern: "POST /api/members", Auth: AuthNone, Status: 201}
|
||||
RouteRemoveTracks = Route{Pattern: "POST /api/tracks/removals", Auth: AuthNone, Status: 204}
|
||||
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
|
||||
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
|
||||
RouteGetSeat = Route{Pattern: "GET /api/rooms/{ref}/seats/{seat}", Auth: AuthMember, Status: 200}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
import type { ErrorInfo, Member, Room } from '../realtime/protocol.gen'
|
||||
import type { ErrorInfo, Member, Room, Track } from '../realtime/protocol.gen'
|
||||
|
||||
export interface AdminUpdateRoomRequest {
|
||||
homepage?: string
|
||||
@@ -25,6 +25,10 @@ export interface CreateRoomResponse {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface RemoveTracksRequest {
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: ErrorInfo
|
||||
}
|
||||
@@ -43,6 +47,7 @@ export type HttpSend = <T>(request: HttpRequest) => Promise<T>
|
||||
export function httpClient(send: HttpSend) {
|
||||
return {
|
||||
createMember: () => send<CreateMemberResponse>({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
|
||||
removeTracks: (body: RemoveTracksRequest) => send<void>({ method: 'POST', path: '/api/tracks/removals', auth: 'none', body }),
|
||||
getRoom: (ref: string) => send<Room>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
|
||||
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
|
||||
getSeat: (ref: string, seat: string) => send<Member>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}/seats/${encodeURIComponent(seat)}`, auth: 'member' }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
export const PROTOCOL_VERSION = '362d144af81ba8bf'
|
||||
export const PROTOCOL_VERSION = '5e495c1e23fa03a5'
|
||||
|
||||
export type Empty = Record<string, never>
|
||||
|
||||
|
||||
@@ -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,10 @@ services:
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
# traefik 经这个网络转发请求,serve 默认信任这个网段传来的 X-Forwarded-For。与已有的 docker 网络冲突时改这里,并同步修改 ALISTEN_TRUSTED_PROXIES。
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.31.250.0/24
|
||||
backend:
|
||||
name: alisten-backend
|
||||
db:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
+34
-4
@@ -1,14 +1,44 @@
|
||||
-- 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: 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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
domain: ""
|
||||
allowedOrigins: ""
|
||||
# 逗号分隔的 CIDR:只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信,用于按客户端 IP 限流。
|
||||
trustedProxies: ""
|
||||
logLevel: info
|
||||
otlpEndpoint: ""
|
||||
database:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -21,10 +22,12 @@ 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"
|
||||
@@ -36,17 +39,22 @@ const (
|
||||
)
|
||||
|
||||
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 +71,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 +108,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.handle(mux, protocol.RouteRemoveMyPlaylistItems, srv.removeMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists)
|
||||
srv.handle(mux, protocol.RouteCopyMyPlaylistItems, srv.copyMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteMoveMyPlaylistItems, 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 +151,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)
|
||||
@@ -180,7 +210,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque
|
||||
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 +226,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 {
|
||||
@@ -276,6 +306,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},
|
||||
|
||||
@@ -42,6 +42,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,
|
||||
@@ -298,7 +299,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("连接中断")
|
||||
}
|
||||
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 看到它就该停下,而不是重连之后互相顶掉。
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func (s *Store) ResetRoomAPIKey(ctx context.Context, roomID string, k port.NewAP
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
m, err := q.CreateMember(ctx, query.CreateMemberParams{TokenHash: k.BotTokenHash, Name: botName})
|
||||
m, err := q.CreateMember(ctx, botName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package postgres
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func newAPIKey(key string) port.NewAPIKey {
|
||||
return port.NewAPIKey{TokenHash: tokenHash(key), BotTokenHash: tokenHash("bot " + key)}
|
||||
return port.NewAPIKey{TokenHash: tokenHash(key)}
|
||||
}
|
||||
|
||||
func resetAPIKey(t *testing.T, store *Store, roomID, key string) {
|
||||
@@ -58,7 +59,7 @@ func TestResetRoomAPIKey(t *testing.T) {
|
||||
if _, err := store.TicketHolder(t.Context(), tokenHash("k1")); !errors.Is(err, port.ErrTicketNotFound) {
|
||||
t.Errorf("重置之后旧 Key 的错误 = %v,期望 ErrTicketNotFound", err)
|
||||
}
|
||||
if holder, err := store.TicketHolder(t.Context(), tokenHash("k2")); err != nil || holder != bot {
|
||||
if holder, err := store.TicketHolder(t.Context(), tokenHash("k2")); err != nil || !reflect.DeepEqual(holder, bot) {
|
||||
t.Errorf("重置之后新 Key 的持有者 = %#v, %v,期望 bot 成员不变", holder, err)
|
||||
}
|
||||
if n := memberTickets(t, pool, bot.Member.ID); n != 1 {
|
||||
@@ -84,7 +85,7 @@ func TestSettingPasswordKeepsAPIKey(t *testing.T) {
|
||||
if err := store.UpdateRoomAccess(t.Context(), id, port.AccessChange{PasswordHash: ptr("h1")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if holder, err := store.TicketHolder(t.Context(), tokenHash("k1")); err != nil || holder != bot {
|
||||
if holder, err := store.TicketHolder(t.Context(), tokenHash("k1")); err != nil || !reflect.DeepEqual(holder, bot) {
|
||||
t.Errorf("设置密码之后 Key 的持有者 = %#v, %v,期望 Key 仍然有效", holder, err)
|
||||
}
|
||||
if n := ticketCount(t, pool, id); n != 1 {
|
||||
@@ -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,132 @@ 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 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 +142,86 @@ 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 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
|
||||
}
|
||||
@@ -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,103 @@ 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, keepTokenHash []byte) ([][]byte, error) {
|
||||
var deleted [][]byte
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
n, err := q.SetMemberPassword(ctx, query.SetMemberPasswordParams{ID: memberID, PasswordHash: &passwordHash})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return port.ErrMemberNotFound
|
||||
}
|
||||
deleted, err = q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("修改成员 %s 的密码:%w", memberID, err)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
@@ -59,13 +59,13 @@ func TestMemberAvatarIsNullWhenUnset(t *testing.T) {
|
||||
if hash := avatar(); hash != "NULL" {
|
||||
t.Errorf("新成员的 avatar_hash = %q,期望 NULL", hash)
|
||||
}
|
||||
if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346")); 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 +78,14 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
m := createMember(t, store, "alice", "游客-1a2b")
|
||||
|
||||
got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil)
|
||||
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 +96,244 @@ 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.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1"))
|
||||
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
|
||||
t.Errorf("SetPassword 返回 %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("SetPassword 应只留下指定的会话")
|
||||
}
|
||||
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", nil); !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.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1")); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
|
||||
t.Errorf("SetPassword 返回 %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", nil); 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("不指定保留的会话时应删除全部会话,alice-1 仍属于 %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
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 {
|
||||
err := s.owned(ctx, ownerID, id)
|
||||
if err == nil {
|
||||
sources, ids := splitTracks(tracks)
|
||||
err = s.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,538 @@
|
||||
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 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("移动之后更新时间没有推进")
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func addTicket(t *testing.T, pool *pgxpool.Pool, roomID string) {
|
||||
t.Helper()
|
||||
_, err := pool.Exec(t.Context(), `
|
||||
WITH m AS (
|
||||
INSERT INTO members (token_hash, name) VALUES (sha256(gen_random_uuid()::text::bytea), '甲')
|
||||
INSERT INTO members (name) VALUES ('甲')
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO room_tickets (token_hash, room_id, member_id) SELECT sha256(gen_random_uuid()::text::bytea), $1, id FROM m`,
|
||||
|
||||
@@ -12,20 +12,27 @@ import (
|
||||
|
||||
var _ port.Site = (*Store)(nil)
|
||||
|
||||
func (s *Store) BrandName(ctx context.Context) (string, error) {
|
||||
name, err := s.q.BrandName(ctx)
|
||||
func (s *Store) Settings(ctx context.Context) (port.SiteSettings, error) {
|
||||
row, err := s.q.SiteSettings(ctx)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
return port.SiteSettings{RegistrationOpen: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取品牌名:%w", err)
|
||||
return port.SiteSettings{}, fmt.Errorf("读取站点设置:%w", err)
|
||||
}
|
||||
return name, nil
|
||||
return port.SiteSettings{BrandName: orEmpty(row.BrandName), RegistrationOpen: row.RegistrationOpen}, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetBrandName(ctx context.Context, name string) error {
|
||||
if err := s.q.SetBrandName(ctx, name); err != nil {
|
||||
if err := s.q.SetBrandName(ctx, &name); err != nil {
|
||||
return fmt.Errorf("保存品牌名:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetRegistrationOpen(ctx context.Context, open bool) error {
|
||||
if err := s.q.SetRegistrationOpen(ctx, open); err != nil {
|
||||
return fmt.Errorf("保存注册开关:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,20 +1,34 @@
|
||||
package postgres
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
func TestBrandName(t *testing.T) {
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
func TestSiteSettings(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
if name, err := store.BrandName(t.Context()); err != nil || name != "" {
|
||||
t.Fatalf("没有设置过时 = %q, %v,期望空字符串", name, err)
|
||||
check := func(want port.SiteSettings, why string) {
|
||||
t.Helper()
|
||||
if got, err := store.Settings(t.Context()); err != nil || got != want {
|
||||
t.Errorf("%s:Settings = %#v, %v,期望 %#v", why, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"周末听歌会", "Alisten"} {
|
||||
if err := store.SetBrandName(t.Context(), want); err != nil {
|
||||
check(port.SiteSettings{RegistrationOpen: true}, "没有设置过时没有品牌名、开放注册")
|
||||
if err := store.SetRegistrationOpen(t.Context(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(port.SiteSettings{RegistrationOpen: false}, "只关闭注册时品牌名仍未设置")
|
||||
for _, name := range []string{"周末听歌会", "Alisten"} {
|
||||
if err := store.SetBrandName(t.Context(), name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := store.BrandName(t.Context()); err != nil || got != want {
|
||||
t.Errorf("BrandName = %q, %v,期望 %q", got, err, want)
|
||||
}
|
||||
check(port.SiteSettings{BrandName: name, RegistrationOpen: false}, "修改品牌名不影响注册开关")
|
||||
}
|
||||
if err := store.SetRegistrationOpen(t.Context(), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(port.SiteSettings{BrandName: "Alisten", RegistrationOpen: true}, "重新开放注册不影响品牌名")
|
||||
var rows int
|
||||
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM site").Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1"))
|
||||
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
|
||||
t.Errorf("SetPassword 返回 %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", "甲")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"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:"+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:"+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
|
||||
}
|
||||
if _, err := a.verify(ctx, username, currentPassword); errors.Is(err, ErrInvalidCredentials) {
|
||||
return ErrWrongCurrentPassword
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := HashPassword(ctx, newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleted, err := a.members.SetPassword(ctx, memberID, hash, hashSecret(token))
|
||||
if 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, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := a.gateway.RevokeSessions(ctx, deleted); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
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 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", nil); 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 应仍然有效")
|
||||
}
|
||||
}
|
||||
|
||||
// 名额占满、请求已经取消时,要做密码计算的请求以 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
// 常驻房间的 API Key 是房间 bot 成员的票据。
|
||||
type NewAPIKey struct {
|
||||
TokenHash []byte
|
||||
// 房间还没有 bot 成员时用它创建:对应的随机值不交给任何人,bot 因此没有可用的成员 token。
|
||||
BotTokenHash []byte
|
||||
}
|
||||
|
||||
type APIKeyStatus struct {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,12 +7,46 @@ 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
|
||||
// 在同一个事务中修改已注册成员的密码哈希,并删除该成员除 keepTokenHash 以外的会话,keepTokenHash 为 nil 时全部删除。
|
||||
// 返回被删除的会话的 token 哈希。成员是游客时返回 ErrMemberNotFound。
|
||||
SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,113 @@ 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, keepTokenHash []byte) ([][]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, 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 +266,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 +285,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 +327,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 +462,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 +502,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
|
||||
}
|
||||
@@ -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 的邮箱应报错")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
歌单:只有已注册的账号能使用;注册时自带不能重命名、不能删除的“我喜欢”;收藏时元数据由服务端查询,同一首歌只收一次,新加入的在前;可以批量移出、复制到别的歌单、调整顺序;别人的歌单和不存在的一样。
|
||||
|
||||
-- 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 --
|
||||
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"}]}
|
||||
@@ -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
@@ -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"}}
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,44 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBeforeMoved = errors.New("before 不能是要移动的歌")
|
||||
ErrBeforeMissing = errors.New("歌单已经变了,刷新之后再试")
|
||||
)
|
||||
|
||||
// order 是显示顺序;返回移动后的显示顺序。moved 里不在 order 中的歌忽略,重复的只算一次;
|
||||
// 都不在 order 中时原样返回,此时不检查 before 是否还在。
|
||||
func Move(order []track.Track, moved []track.Track, before *track.Track) ([]track.Track, error) {
|
||||
if before != nil && slices.Contains(moved, *before) {
|
||||
return nil, ErrBeforeMoved
|
||||
}
|
||||
var block []track.Track
|
||||
for _, t := range moved {
|
||||
if slices.Contains(order, t) && !slices.Contains(block, t) {
|
||||
block = append(block, t)
|
||||
}
|
||||
}
|
||||
if len(block) == 0 {
|
||||
return slices.Clone(order), nil
|
||||
}
|
||||
rest := make([]track.Track, 0, len(order)-len(block))
|
||||
for _, t := range order {
|
||||
if !slices.Contains(block, t) {
|
||||
rest = append(rest, t)
|
||||
}
|
||||
}
|
||||
at := len(rest)
|
||||
if before != nil {
|
||||
at = slices.Index(rest, *before)
|
||||
if at < 0 {
|
||||
return nil, ErrBeforeMissing
|
||||
}
|
||||
}
|
||||
return slices.Concat(rest[:at], block, rest[at:]), nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
func tr(id string) track.Track { return track.Track{Source: track.NetEase, ID: id} }
|
||||
|
||||
func trs(ids ...string) []track.Track {
|
||||
out := make([]track.Track, len(ids))
|
||||
for i, id := range ids {
|
||||
out[i] = tr(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestMove(t *testing.T) {
|
||||
order := trs("a", "b", "c", "d", "e")
|
||||
at := func(id string) *track.Track { t := tr(id); return &t }
|
||||
tests := []struct {
|
||||
name string
|
||||
moved []track.Track
|
||||
before *track.Track
|
||||
want []track.Track
|
||||
wantErr error
|
||||
}{
|
||||
{"移到最前", trs("d"), at("a"), trs("d", "a", "b", "c", "e"), nil},
|
||||
{"移到最后", trs("b"), nil, trs("a", "c", "d", "e", "b"), nil},
|
||||
{"移到中间", trs("a"), at("d"), trs("b", "c", "a", "d", "e"), nil},
|
||||
{"多首保持给定先后", trs("e", "b"), at("d"), trs("a", "c", "e", "b", "d"), nil},
|
||||
{"多首移到最后保持给定先后", trs("c", "a"), nil, trs("b", "d", "e", "c", "a"), nil},
|
||||
{"忽略不存在的歌", trs("x", "c", "y"), at("a"), trs("c", "a", "b", "d", "e"), nil},
|
||||
{"重复的歌只算一次", trs("c", "c", "b"), nil, trs("a", "d", "e", "c", "b"), nil},
|
||||
{"before 紧跟在移动的歌之后", trs("b"), at("c"), order, nil},
|
||||
{"before 是被移动的歌", trs("b", "c"), at("c"), nil, ErrBeforeMoved},
|
||||
{"before 是不存在的被移动的歌", trs("x"), at("x"), nil, ErrBeforeMoved},
|
||||
{"before 不存在", trs("b"), at("x"), nil, ErrBeforeMissing},
|
||||
{"空移动", nil, at("c"), order, nil},
|
||||
{"移动的歌都不存在", trs("x"), at("y"), order, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
before := slices.Clone(order)
|
||||
got, err := Move(order, tt.moved, tt.before)
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("err = %v,期望 %v", err, tt.wantErr)
|
||||
}
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("Move = %v,期望 %v", got, tt.want)
|
||||
}
|
||||
if !slices.Equal(order, before) {
|
||||
t.Errorf("order 被修改:%v", order)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
var (
|
||||
patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
|
||||
patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`)
|
||||
patternLoginRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
||||
patternRegisterRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
||||
patternTrackIDWhenBilibili = regexp.MustCompile(`^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$`)
|
||||
)
|
||||
|
||||
@@ -72,6 +74,46 @@ func DecodeUpdateMeRequest(body []byte) (UpdateMeRequest, error) {
|
||||
return decodeUpdateMeRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeRegisterRequest(body []byte) (RegisterRequest, error) {
|
||||
return decodeRegisterRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeChangePasswordRequest(body []byte) (ChangePasswordRequest, error) {
|
||||
return decodeChangePasswordRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeLoginRequest(body []byte) (LoginRequest, error) {
|
||||
return decodeLoginRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeCreateMyPlaylistRequest(body []byte) (CreateMyPlaylistRequest, error) {
|
||||
return decodeCreateMyPlaylistRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeRenameMyPlaylistRequest(body []byte) (RenameMyPlaylistRequest, error) {
|
||||
return decodeRenameMyPlaylistRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeAddMyPlaylistItemRequest(body []byte) (AddMyPlaylistItemRequest, error) {
|
||||
return decodeAddMyPlaylistItemRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeRemoveMyPlaylistItemsRequest(body []byte) (RemoveMyPlaylistItemsRequest, error) {
|
||||
return decodeRemoveMyPlaylistItemsRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeCopyMyPlaylistItemsRequest(body []byte) (CopyMyPlaylistItemsRequest, error) {
|
||||
return decodeCopyMyPlaylistItemsRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeMoveMyPlaylistItemsRequest(body []byte) (MoveMyPlaylistItemsRequest, error) {
|
||||
return decodeMoveMyPlaylistItemsRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeListMyTrackPlaylistsRequest(body []byte) (ListMyTrackPlaylistsRequest, error) {
|
||||
return decodeListMyTrackPlaylistsRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
|
||||
return decodeCreateRoomRequest(body, "")
|
||||
}
|
||||
@@ -124,6 +166,29 @@ func DecodeAdminUpdateSiteRequest(body []byte) (AdminUpdateSiteRequest, error) {
|
||||
return decodeAdminUpdateSiteRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeAdminOpenRegistrationRequest(body []byte) (AdminOpenRegistrationRequest, error) {
|
||||
return decodeAdminOpenRegistrationRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeAdminResetAccountPasswordRequest(body []byte) (AdminResetAccountPasswordRequest, error) {
|
||||
return decodeAdminResetAccountPasswordRequest(body, "")
|
||||
}
|
||||
|
||||
func decodeAddMyPlaylistItemRequest(raw json.RawMessage, path string) (AddMyPlaylistItemRequest, error) {
|
||||
var v AddMyPlaylistItemRequest
|
||||
m, err := decodeObject(raw, path, "track")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "track"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Track, err = decodeTrack(m["track"], field(path, "track")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeAdminCheckBilibiliLoginRequest(raw json.RawMessage, path string) (AdminCheckBilibiliLoginRequest, error) {
|
||||
var v AdminCheckBilibiliLoginRequest
|
||||
m, err := decodeObject(raw, path, "key")
|
||||
@@ -225,6 +290,18 @@ func decodeAdminCreateRoomRequest(raw json.RawMessage, path string) (AdminCreate
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeAdminOpenRegistrationRequest(raw json.RawMessage, path string) (AdminOpenRegistrationRequest, error) {
|
||||
var v AdminOpenRegistrationRequest
|
||||
_, err := decodeObject(raw, path)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func decodeAdminResetAccountPasswordRequest(raw json.RawMessage, path string) (AdminResetAccountPasswordRequest, error) {
|
||||
var v AdminResetAccountPasswordRequest
|
||||
_, err := decodeObject(raw, path)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func decodeAdminResetRoomApiKeyRequest(raw json.RawMessage, path string) (AdminResetRoomApiKeyRequest, error) {
|
||||
var v AdminResetRoomApiKeyRequest
|
||||
_, err := decodeObject(raw, path)
|
||||
@@ -340,6 +417,36 @@ func decodeAdminUpdateSiteRequest(raw json.RawMessage, path string) (AdminUpdate
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeChangePasswordRequest(raw json.RawMessage, path string) (ChangePasswordRequest, error) {
|
||||
var v ChangePasswordRequest
|
||||
m, err := decodeObject(raw, path, "currentPassword", "newPassword")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "currentPassword", "newPassword"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.CurrentPassword, err = decodeString(m["currentPassword"], field(path, "currentPassword")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinLength(v.CurrentPassword, field(path, "currentPassword"), 8); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(v.CurrentPassword, field(path, "currentPassword"), 128); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.NewPassword, err = decodeString(m["newPassword"], field(path, "newPassword")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinLength(v.NewPassword, field(path, "newPassword"), 8); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(v.NewPassword, field(path, "newPassword"), 128); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeChatSendRequest(raw json.RawMessage, path string) (ChatSendRequest, error) {
|
||||
var v ChatSendRequest
|
||||
m, err := decodeObject(raw, path, "content")
|
||||
@@ -355,12 +462,54 @@ func decodeChatSendRequest(raw json.RawMessage, path string) (ChatSendRequest, e
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeCopyMyPlaylistItemsRequest(raw json.RawMessage, path string) (CopyMyPlaylistItemsRequest, error) {
|
||||
var v CopyMyPlaylistItemsRequest
|
||||
m, err := decodeObject(raw, path, "from", "tracks")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "from", "tracks"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.From, err = decodeString(m["from"], field(path, "from")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeCreateMemberRequest(raw json.RawMessage, path string) (CreateMemberRequest, error) {
|
||||
var v CreateMemberRequest
|
||||
_, err := decodeObject(raw, path)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func decodeCreateMyPlaylistRequest(raw json.RawMessage, path string) (CreateMyPlaylistRequest, error) {
|
||||
var v CreateMyPlaylistRequest
|
||||
m, err := decodeObject(raw, path, "name")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "name"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(v.Name, field(path, "name"), 200); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeCreateRoomRequest(raw json.RawMessage, path string) (CreateRoomRequest, error) {
|
||||
var v CreateRoomRequest
|
||||
m, err := decodeObject(raw, path, "description", "name", "password")
|
||||
@@ -432,6 +581,76 @@ func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeListMyTrackPlaylistsRequest(raw json.RawMessage, path string) (ListMyTrackPlaylistsRequest, error) {
|
||||
var v ListMyTrackPlaylistsRequest
|
||||
m, err := decodeObject(raw, path, "track")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "track"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Track, err = decodeTrack(m["track"], field(path, "track")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeLoginRequest(raw json.RawMessage, path string) (LoginRequest, error) {
|
||||
var v LoginRequest
|
||||
m, err := decodeObject(raw, path, "password", "username")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "password", "username"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Password, err = decodeString(m["password"], field(path, "password")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinLength(v.Password, field(path, "password"), 8); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(v.Password, field(path, "password"), 128); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Username, err = decodeString(m["username"], field(path, "username")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkPattern(v.Username, field(path, "username"), patternLoginRequestUsername); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeMoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (MoveMyPlaylistItemsRequest, error) {
|
||||
var v MoveMyPlaylistItemsRequest
|
||||
m, err := decodeObject(raw, path, "before", "tracks")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "tracks"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if r, ok := m["before"]; ok {
|
||||
x, err := decodeTrack(r, field(path, "before"))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Before = &x
|
||||
}
|
||||
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodePingRequest(raw json.RawMessage, path string) (PingRequest, error) {
|
||||
var v PingRequest
|
||||
m, err := decodeObject(raw, path, "clientTimeMs")
|
||||
@@ -583,6 +802,72 @@ func decodeRecommendListRequest(raw json.RawMessage, path string) (RecommendList
|
||||
return v, err
|
||||
}
|
||||
|
||||
func decodeRegisterRequest(raw json.RawMessage, path string) (RegisterRequest, error) {
|
||||
var v RegisterRequest
|
||||
m, err := decodeObject(raw, path, "password", "username")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "password", "username"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Password, err = decodeString(m["password"], field(path, "password")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinLength(v.Password, field(path, "password"), 8); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(v.Password, field(path, "password"), 128); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Username, err = decodeString(m["username"], field(path, "username")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkPattern(v.Username, field(path, "username"), patternRegisterRequestUsername); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeRemoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (RemoveMyPlaylistItemsRequest, error) {
|
||||
var v RemoveMyPlaylistItemsRequest
|
||||
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)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeRenameMyPlaylistRequest(raw json.RawMessage, path string) (RenameMyPlaylistRequest, error) {
|
||||
var v RenameMyPlaylistRequest
|
||||
m, err := decodeObject(raw, path, "name")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "name"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(v.Name, field(path, "name"), 200); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
|
||||
var v RoomSyncRequest
|
||||
_, err := decodeObject(raw, path)
|
||||
|
||||
@@ -88,6 +88,38 @@ func decodeInteger(raw json.RawMessage, path string) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func decodeArray[T any](decode func(json.RawMessage, string) (T, error)) func(json.RawMessage, string) ([]T, error) {
|
||||
return func(raw json.RawMessage, path string) ([]T, error) {
|
||||
var elems []json.RawMessage
|
||||
if bytes.Equal(raw, null) || json.Unmarshal(raw, &elems) != nil {
|
||||
return nil, invalid(path, "应为数组")
|
||||
}
|
||||
out := make([]T, len(elems))
|
||||
for i, e := range elems {
|
||||
v, err := decode(e, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkMinItems[T any](a []T, path string, n int) error {
|
||||
if len(a) < n {
|
||||
return invalid(path, fmt.Sprintf("元素不能少于 %d 个", n))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMaxItems[T any](a []T, path string, n int) error {
|
||||
if len(a) > n {
|
||||
return invalid(path, fmt.Sprintf("元素不能超过 %d 个", n))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMinLength(s, path string, n int) error {
|
||||
if utf8.RuneCountInString(s) < n {
|
||||
return invalid(path, fmt.Sprintf("长度不能少于 %d 个字符", n))
|
||||
|
||||
@@ -3,6 +3,7 @@ package protocol
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -67,3 +68,27 @@ func TestCheckOneOf(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTrackArray(t *testing.T) {
|
||||
one := `{"source":"netease","id":"1"}`
|
||||
tests := []struct {
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{`{"tracks":[` + one + `]}`, ""},
|
||||
{`{"tracks":[]}`, "tracks:元素不能少于 1 个"},
|
||||
{`{"tracks":null}`, "tracks:应为数组"},
|
||||
{`{"tracks":[{"source":"netease"}]}`, "tracks[0].id:缺少必填字段"},
|
||||
{`{}`, "tracks:缺少必填字段"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
_, err := DecodeRemoveMyPlaylistItemsRequest([]byte(tt.body))
|
||||
if got := errText(err); got != tt.want {
|
||||
t.Errorf("%s = %q,期望 %q", tt.body, got, tt.want)
|
||||
}
|
||||
}
|
||||
tooMany := `{"tracks":[` + one + strings.Repeat(","+one, 1000) + `]}`
|
||||
if got := errText(func() error { _, err := DecodeRemoveMyPlaylistItemsRequest([]byte(tooMany)); return err }()); got != "tracks:元素不能超过 1000 个" {
|
||||
t.Errorf("1001 首 = %q", got)
|
||||
}
|
||||
}
|
||||
+364
-6
@@ -12,6 +12,7 @@
|
||||
"enum": [
|
||||
"INVALID_ARGUMENT",
|
||||
"UNAUTHORIZED",
|
||||
"INVALID_CREDENTIALS",
|
||||
"FORBIDDEN",
|
||||
"NOT_FOUND",
|
||||
"CONFLICT",
|
||||
@@ -26,6 +27,7 @@
|
||||
"x-httpStatus": {
|
||||
"INVALID_ARGUMENT": 400,
|
||||
"UNAUTHORIZED": 401,
|
||||
"INVALID_CREDENTIALS": 401,
|
||||
"FORBIDDEN": 403,
|
||||
"NOT_FOUND": 404,
|
||||
"CONFLICT": 409,
|
||||
@@ -139,6 +141,59 @@
|
||||
},
|
||||
"required": ["id", "name", "avatarHash"]
|
||||
},
|
||||
"Username": {
|
||||
"type": "string",
|
||||
"description": "用户名:3 到 32 个小写字母、数字、下划线或连字符",
|
||||
"pattern": "^[a-z0-9_-]{3,32}$"
|
||||
},
|
||||
"AccountPassword": {
|
||||
"type": "string",
|
||||
"description": "账号密码",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
},
|
||||
"Account": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"username": {"type": "string"},
|
||||
"email": {"type": "string", "description": "账号设置了邮箱时才有"}
|
||||
},
|
||||
"required": ["username"]
|
||||
},
|
||||
"AdminAccount": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"member": {"$ref": "#/$defs/Member"},
|
||||
"username": {"type": "string"}
|
||||
},
|
||||
"required": ["member", "username"]
|
||||
},
|
||||
"PlaylistName": {"type": "string", "description": "歌单名:去掉首尾空白后 1 到 50 个字符", "maxLength": 200},
|
||||
"MyPlaylist": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"favorites": {"type": "boolean", "description": "是否是“我喜欢”"},
|
||||
"trackCount": {"type": "integer"},
|
||||
"createdAtMs": {"type": "integer"},
|
||||
"updatedAtMs": {"type": "integer"},
|
||||
"coverUrl": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "歌单里排在最前、有封面的那首歌的封面;没有时为 null"}
|
||||
},
|
||||
"required": ["id", "name", "favorites", "trackCount", "createdAtMs", "updatedAtMs", "coverUrl"]
|
||||
},
|
||||
"MyPlaylistItem": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {"$ref": "#/$defs/Track"},
|
||||
"meta": {"$ref": "#/$defs/TrackMeta"}
|
||||
},
|
||||
"required": ["track", "meta"]
|
||||
},
|
||||
"Members": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -658,7 +713,7 @@
|
||||
},
|
||||
{
|
||||
"name": "getMe",
|
||||
"description": "当前成员的资料",
|
||||
"description": "当前成员的资料;已注册的成员带上账号",
|
||||
"method": "GET",
|
||||
"path": "/api/me",
|
||||
"auth": "member",
|
||||
@@ -666,14 +721,15 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"member": {"$ref": "#/$defs/Member"}
|
||||
"member": {"$ref": "#/$defs/Member"},
|
||||
"account": {"$ref": "#/$defs/Account"}
|
||||
},
|
||||
"required": ["member"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "updateMe",
|
||||
"description": "修改昵称和头像;email 只用来计算头像哈希,空字符串表示清除头像",
|
||||
"description": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除",
|
||||
"method": "PATCH",
|
||||
"path": "/api/me",
|
||||
"auth": "member",
|
||||
@@ -694,6 +750,258 @@
|
||||
"required": ["member"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "register",
|
||||
"description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN",
|
||||
"method": "POST",
|
||||
"path": "/api/account",
|
||||
"auth": "member",
|
||||
"status": 201,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"username": {"$ref": "#/$defs/Username"},
|
||||
"password": {"$ref": "#/$defs/AccountPassword"}
|
||||
},
|
||||
"required": ["username", "password"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"account": {"$ref": "#/$defs/Account"}
|
||||
},
|
||||
"required": ["account"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "changePassword",
|
||||
"description": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS",
|
||||
"method": "PUT",
|
||||
"path": "/api/account/password",
|
||||
"auth": "member",
|
||||
"status": 204,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"currentPassword": {"$ref": "#/$defs/AccountPassword"},
|
||||
"newPassword": {"$ref": "#/$defs/AccountPassword"}
|
||||
},
|
||||
"required": ["currentPassword", "newPassword"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "login",
|
||||
"description": "游客登录账号:返回账号的新成员 token,发出请求的游客 token 随即失效;用户名或密码错误时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT",
|
||||
"method": "POST",
|
||||
"path": "/api/sessions",
|
||||
"auth": "member",
|
||||
"status": 201,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"username": {"$ref": "#/$defs/Username"},
|
||||
"password": {"$ref": "#/$defs/AccountPassword"}
|
||||
},
|
||||
"required": ["username", "password"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"memberToken": {"type": "string"},
|
||||
"member": {"$ref": "#/$defs/Member"},
|
||||
"account": {"$ref": "#/$defs/Account"}
|
||||
},
|
||||
"required": ["memberToken", "member", "account"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "logout",
|
||||
"description": "退出登录:发出请求的成员 token 随即失效",
|
||||
"method": "DELETE",
|
||||
"path": "/api/sessions/current",
|
||||
"auth": "member",
|
||||
"status": 204
|
||||
},
|
||||
{
|
||||
"name": "listMyPlaylists",
|
||||
"description": "我的歌单:“我喜欢”在最前,其余按创建的先后;只有已注册的账号能使用歌单",
|
||||
"method": "GET",
|
||||
"path": "/api/me/playlists",
|
||||
"auth": "member",
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylist"}}
|
||||
},
|
||||
"required": ["items"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "createMyPlaylist",
|
||||
"description": "新建歌单:最多 50 个(含“我喜欢”),可以重名",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlists",
|
||||
"auth": "member",
|
||||
"status": 201,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {"$ref": "#/$defs/PlaylistName"}
|
||||
},
|
||||
"required": ["name"]
|
||||
},
|
||||
"response": {"$ref": "#/$defs/MyPlaylist"}
|
||||
},
|
||||
{
|
||||
"name": "renameMyPlaylist",
|
||||
"description": "重命名歌单;“我喜欢”不能重命名",
|
||||
"method": "PATCH",
|
||||
"path": "/api/me/playlists/{id}",
|
||||
"auth": "member",
|
||||
"status": 204,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {"$ref": "#/$defs/PlaylistName"}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deleteMyPlaylist",
|
||||
"description": "删除歌单;“我喜欢”不能删除",
|
||||
"method": "DELETE",
|
||||
"path": "/api/me/playlists/{id}",
|
||||
"auth": "member",
|
||||
"status": 204
|
||||
},
|
||||
{
|
||||
"name": "listMyPlaylistItems",
|
||||
"description": "歌单里的歌,新加入的在前",
|
||||
"method": "GET",
|
||||
"path": "/api/me/playlists/{id}/items",
|
||||
"auth": "member",
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylistItem"}}
|
||||
},
|
||||
"required": ["items"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "addMyPlaylistItem",
|
||||
"description": "把一首歌加入歌单:元数据由服务端查询,链接曲目要先准备好;已经在歌单里时不变;一个歌单最多 1000 首",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlists/{id}/items",
|
||||
"auth": "member",
|
||||
"status": 201,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {"$ref": "#/$defs/Track"}
|
||||
},
|
||||
"required": ["track"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"item": {"$ref": "#/$defs/MyPlaylistItem"}
|
||||
},
|
||||
"required": ["item"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "removeMyPlaylistItems",
|
||||
"description": "把若干首歌移出歌单,一次 1 到 1000 首;不在歌单里的忽略",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlists/{id}/removals",
|
||||
"auth": "member",
|
||||
"status": 204,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 1000}
|
||||
},
|
||||
"required": ["tracks"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "copyMyPlaylistItems",
|
||||
"description": "把另一个歌单里的若干首歌连同元数据复制到这个歌单,一次 1 到 1000 首;不在来源歌单里的和已在这个歌单里的跳过;新复制的排在最前,保持 tracks 的先后;装不下时整批拒绝;返回实际复制的数量",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlists/{id}/copies",
|
||||
"auth": "member",
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"from": {"type": "string", "description": "来源歌单的 ID"},
|
||||
"tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 1000}
|
||||
},
|
||||
"required": ["from", "tracks"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"added": {"type": "integer"}
|
||||
},
|
||||
"required": ["added"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "moveMyPlaylistItems",
|
||||
"description": "调整歌单里的顺序:把 tracks 里在歌单中的歌按给出的先后放到 before 之前,不给 before 就放到最后;一次 1 到 1000 首;before 不能在 tracks 里,也必须还在歌单里",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlists/{id}/moves",
|
||||
"auth": "member",
|
||||
"status": 204,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 1000},
|
||||
"before": {"$ref": "#/$defs/Track"}
|
||||
},
|
||||
"required": ["tracks"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "listMyTrackPlaylists",
|
||||
"description": "这首歌在我的哪些歌单里",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlist-memberships",
|
||||
"auth": "member",
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {"$ref": "#/$defs/Track"}
|
||||
},
|
||||
"required": ["track"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"playlistIds": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
"required": ["playlistIds"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "listRooms",
|
||||
"description": "房间列表",
|
||||
@@ -769,7 +1077,7 @@
|
||||
},
|
||||
{
|
||||
"name": "getSite",
|
||||
"description": "品牌名;没有设置过时是 Alisten",
|
||||
"description": "品牌名和是否开放注册;没有设置过品牌名时是 Alisten",
|
||||
"method": "GET",
|
||||
"path": "/api/site",
|
||||
"auth": "none",
|
||||
@@ -777,9 +1085,10 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"brandName": {"type": "string"}
|
||||
"brandName": {"type": "string"},
|
||||
"registrationOpen": {"type": "boolean"}
|
||||
},
|
||||
"required": ["brandName"]
|
||||
"required": ["brandName", "registrationOpen"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1097,6 +1406,55 @@
|
||||
},
|
||||
"required": ["brandName"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "adminOpenRegistration",
|
||||
"description": "开放注册",
|
||||
"method": "PUT",
|
||||
"path": "/api/admin/registration",
|
||||
"auth": "admin",
|
||||
"status": 204,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "adminCloseRegistration",
|
||||
"description": "关闭注册:不能再注册新账号,已有账号照常登录,游客照常使用",
|
||||
"method": "DELETE",
|
||||
"path": "/api/admin/registration",
|
||||
"auth": "admin",
|
||||
"status": 204
|
||||
},
|
||||
{
|
||||
"name": "adminGetAccount",
|
||||
"description": "按用户名查找账号",
|
||||
"method": "GET",
|
||||
"path": "/api/admin/accounts/{username}",
|
||||
"auth": "admin",
|
||||
"response": {"$ref": "#/$defs/AdminAccount"}
|
||||
},
|
||||
{
|
||||
"name": "adminResetAccountPassword",
|
||||
"description": "重置账号的密码:生成随机的新密码,只在这次响应中返回;账号在所有设备上的成员 token 随即失效",
|
||||
"method": "POST",
|
||||
"path": "/api/admin/accounts/{username}/password",
|
||||
"auth": "admin",
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"password": {"type": "string"}
|
||||
},
|
||||
"required": ["password"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+1143
-5
File diff suppressed because it is too large.
Load diff
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user