feat: expose device pairing over HTTP

This commit is contained in:
DiheChen committed 2026-11-10 12:50:18 +08:00
1 parent 50a865e2a3
commit 88f8e2f957
23 files changed
+587 -75

No files matched your search

+39 -6
View File
@@ -8,12 +8,13 @@ import (
)
var (
patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`)
patternLoginRequestDevice = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
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}))?$`)
patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`)
patternLoginRequestDevice = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
patternLoginRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
patternRedeemPairingCodeRequestCode = regexp.MustCompile(`^[2-9A-HJ-NP-Z]{8}$`)
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}))?$`)
)
func decodeConnectionRequest(typ string, raw json.RawMessage, path string) (any, bool, error) {
@@ -203,6 +204,14 @@ func DecodeCopySharedPlaylistRequest(body []byte) (CopySharedPlaylistRequest, er
return decodeCopySharedPlaylistRequest(body, "")
}
func DecodeCreatePairingCodeRequest(body []byte) (CreatePairingCodeRequest, error) {
return decodeCreatePairingCodeRequest(body, "")
}
func DecodeRedeemPairingCodeRequest(body []byte) (RedeemPairingCodeRequest, error) {
return decodeRedeemPairingCodeRequest(body, "")
}
func decodeAddMyPlaylistItemRequest(raw json.RawMessage, path string) (AddMyPlaylistItemRequest, error) {
var v AddMyPlaylistItemRequest
m, err := decodeObject(raw, path, "track")
@@ -554,6 +563,12 @@ func decodeCreateMyPlaylistRequest(raw json.RawMessage, path string) (CreateMyPl
return v, nil
}
func decodeCreatePairingCodeRequest(raw json.RawMessage, path string) (CreatePairingCodeRequest, error) {
var v CreatePairingCodeRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeCreateRoomRequest(raw json.RawMessage, path string) (CreateRoomRequest, error) {
var v CreateRoomRequest
m, err := decodeObject(raw, path, "description", "name", "password")
@@ -980,6 +995,24 @@ func decodeRecommendListRequest(raw json.RawMessage, path string) (RecommendList
return v, err
}
func decodeRedeemPairingCodeRequest(raw json.RawMessage, path string) (RedeemPairingCodeRequest, error) {
var v RedeemPairingCodeRequest
m, err := decodeObject(raw, path, "code")
if err != nil {
return v, err
}
if err = requireFields(m, path, "code"); err != nil {
return v, err
}
if v.Code, err = decodeString(m["code"], field(path, "code")); err != nil {
return v, err
}
if err = checkPattern(v.Code, field(path, "code"), patternRedeemPairingCodeRequestCode); err != nil {
return v, err
}
return v, nil
}
func decodeRegisterRequest(raw json.RawMessage, path string) (RegisterRequest, error) {
var v RegisterRequest
m, err := decodeObject(raw, path, "password", "username")
+60 -5
View File
@@ -161,10 +161,11 @@
"type": "object",
"additionalProperties": false,
"properties": {
"username": {"type": "string"},
"email": {"type": "string", "description": "账号设置了邮箱时才有"}
"username": {"type": "string", "description": "设置了用户名和密码时才有,只绑定了 GitHub 的账号没有"},
"email": {"type": "string", "description": "账号设置了邮箱时才有"},
"github": {"type": "boolean", "description": "绑定了 GitHub"}
},
"required": ["username"]
"required": ["github"]
},
"AdminAccount": {
"type": "object",
@@ -375,6 +376,11 @@
"items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylistItem"}, "description": "新加入的在前"}
},
"required": ["name", "favorites", "ownerName", "ownerAvatarHash", "items"]
},
"PairingCode": {
"type": "string",
"description": "配对码:8 位,由数字 2–9 和除 I、O 以外的大写字母组成",
"pattern": "^[2-9A-HJ-NP-Z]{8}$"
}
},
"x-ws": {
@@ -795,7 +801,7 @@
},
{
"name": "register",
"description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN",
"description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制",
"method": "POST",
"path": "/api/account",
"auth": "member",
@@ -820,7 +826,7 @@
},
{
"name": "changePassword",
"description": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS",
"description": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS,账号还没有设置密码时返回 CONFLICT",
"method": "PUT",
"path": "/api/account/password",
"auth": "member",
@@ -1686,6 +1692,55 @@
"required": ["name"]
},
"response": {"$ref": "#/$defs/MyPlaylist"}
},
{
"name": "createPairingCode",
"description": "已注册的账号生成配对码,新设备兑换之后登录这个账号:5 分钟内有效,只能兑换一次,发出请求的成员 token 失效时随之作废;游客调用时返回 FORBIDDEN",
"method": "POST",
"path": "/api/account/pairing-codes",
"auth": "member",
"status": 201,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {}
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"code": {"$ref": "#/$defs/PairingCode"},
"qrImage": {"type": "string", "description": "内容为 ALISTEN_PUBLIC_URL/pair#<配对码> 的二维码,PNG 的 data: URI"},
"expiresAtMs": {"type": "integer"}
},
"required": ["code", "qrImage", "expiresAtMs"]
}
},
{
"name": "redeemPairingCode",
"description": "游客兑换配对码,登录生成它的账号:返回账号的新成员 token,发出请求的游客 token 随即失效;配对码不对或已过期时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT",
"method": "POST",
"path": "/api/sessions/pairing",
"auth": "member",
"status": 201,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"code": {"$ref": "#/$defs/PairingCode"}
},
"required": ["code"]
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"memberToken": {"type": "string"},
"member": {"$ref": "#/$defs/Member"},
"account": {"$ref": "#/$defs/Account"}
},
"required": ["memberToken", "member", "account"]
}
}
]
}
+156 -4
View File
@@ -21,12 +21,17 @@
"description": "账号设置了邮箱时才有",
"type": "string"
},
"github": {
"description": "绑定了 GitHub",
"type": "boolean"
},
"username": {
"description": "设置了用户名和密码时才有,只绑定了 GitHub 的账号没有",
"type": "string"
}
},
"required": [
"username"
"github"
],
"type": "object"
},
@@ -523,6 +528,33 @@
],
"type": "object"
},
"CreatePairingCodeRequest": {
"additionalProperties": false,
"type": "object"
},
"CreatePairingCodeResponse": {
"additionalProperties": false,
"properties": {
"code": {
"description": "配对码:8 位,由数字 2–9 和除 I、O 以外的大写字母组成",
"pattern": "^[2-9A-HJ-NP-Z]{8}$",
"type": "string"
},
"expiresAtMs": {
"type": "integer"
},
"qrImage": {
"description": "内容为 ALISTEN_PUBLIC_URL/pair#<配对码> 的二维码,PNG 的 data: URI",
"type": "string"
}
},
"required": [
"code",
"qrImage",
"expiresAtMs"
],
"type": "object"
},
"CreateRoomRequest": {
"additionalProperties": false,
"properties": {
@@ -1425,6 +1457,40 @@
],
"type": "object"
},
"RedeemPairingCodeRequest": {
"additionalProperties": false,
"properties": {
"code": {
"description": "配对码:8 位,由数字 2–9 和除 I、O 以外的大写字母组成",
"pattern": "^[2-9A-HJ-NP-Z]{8}$",
"type": "string"
}
},
"required": [
"code"
],
"type": "object"
},
"RedeemPairingCodeResponse": {
"additionalProperties": false,
"properties": {
"account": {
"$ref": "#/components/schemas/Account"
},
"member": {
"$ref": "#/components/schemas/Member"
},
"memberToken": {
"type": "string"
}
},
"required": [
"memberToken",
"member",
"account"
],
"type": "object"
},
"RegisterRequest": {
"additionalProperties": false,
"properties": {
@@ -2033,7 +2099,7 @@
},
"info": {
"title": "Alisten",
"version": "cbfa923a9cbe9f0a"
"version": "0023496d94e1b43f"
},
"openapi": "3.1.0",
"paths": {
@@ -2077,7 +2143,50 @@
"memberToken": []
}
],
"summary": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN"
"summary": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制"
}
},
"/api/account/pairing-codes": {
"post": {
"operationId": "createPairingCode",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePairingCodeRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePairingCodeResponse"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "已注册的账号生成配对码,新设备兑换之后登录这个账号:5 分钟内有效,只能兑换一次,发出请求的成员 token 失效时随之作废;游客调用时返回 FORBIDDEN"
}
},
"/api/account/password": {
@@ -2113,7 +2222,7 @@
"memberToken": []
}
],
"summary": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS"
"summary": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS,账号还没有设置密码时返回 CONFLICT"
}
},
"/api/admin/accounts/{username}": {
@@ -4772,6 +4881,49 @@
"summary": "退出登录:发出请求的成员 token 随即失效"
}
},
"/api/sessions/pairing": {
"post": {
"operationId": "redeemPairingCode",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RedeemPairingCodeRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RedeemPairingCodeResponse"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "游客兑换配对码,登录生成它的账号:返回账号的新成员 token,发出请求的游客 token 随即失效;配对码不对或已过期时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT"
}
},
"/api/shared-playlists/{shareId}": {
"get": {
"operationId": "getSharedPlaylist",
+23 -2
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "cbfa923a9cbe9f0a"
const Version = "0023496d94e1b43f"
type APIKey struct {
Key string `json:"key"`
@@ -12,7 +12,8 @@ type APIKey struct {
type Account struct {
Email *string `json:"email,omitempty"`
Username string `json:"username"`
Github bool `json:"github"`
Username *string `json:"username,omitempty"`
}
type ActivityKind string
@@ -196,6 +197,14 @@ type CreateMyPlaylistRequest struct {
Name string `json:"name"`
}
type CreatePairingCodeRequest struct{}
type CreatePairingCodeResponse struct {
Code string `json:"code"`
ExpiresAtMs int64 `json:"expiresAtMs"`
QrImage string `json:"qrImage"`
}
type CreateRoomRequest struct {
Description string `json:"description"`
Name string `json:"name"`
@@ -598,6 +607,16 @@ func (v RecommendListResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(plain(v))
}
type RedeemPairingCodeRequest struct {
Code string `json:"code"`
}
type RedeemPairingCodeResponse struct {
Account Account `json:"account"`
Member Member `json:"member"`
MemberToken string `json:"memberToken"`
}
type RegisterRequest struct {
Password string `json:"password"`
Username string `json:"username"`
@@ -926,6 +945,8 @@ var (
RouteUnshareMyPlaylist = Route{Pattern: "DELETE /api/me/playlists/{id}/share", Auth: AuthMember, Status: 204}
RouteGetSharedPlaylist = Route{Pattern: "GET /api/shared-playlists/{shareId}", Auth: AuthNone, Status: 200}
RouteCopySharedPlaylist = Route{Pattern: "POST /api/shared-playlists/{shareId}/copies", Auth: AuthMember, Status: 201}
RouteCreatePairingCode = Route{Pattern: "POST /api/account/pairing-codes", Auth: AuthMember, Status: 201}
RouteRedeemPairingCode = Route{Pattern: "POST /api/sessions/pairing", Auth: AuthMember, Status: 201}
)
var RoomRoutes = []RoomRoute{