feat: add backend, database, and protocol foundations

This commit is contained in:
DiheChen committed 2026-10-30 01:32:19 +08:00
commit ec08d487e1
179 files changed
+27079

No files matched your search

+758
View File
@@ -0,0 +1,758 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
package protocol
import (
"encoding/json"
"regexp"
)
var (
patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,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) {
switch typ {
case RequestHello:
v, err := decodeHelloRequest(raw, path)
return v, true, err
case RequestPing:
v, err := decodePingRequest(raw, path)
return v, true, err
}
return nil, false, nil
}
func decodeRoomRequest(typ string, raw json.RawMessage, path string) (any, bool, error) {
switch typ {
case RequestRoomSync:
v, err := decodeRoomSyncRequest(raw, path)
return v, true, err
case RequestQueueAdd:
v, err := decodeQueueAddRequest(raw, path)
return v, true, err
case RequestQueueRemove:
v, err := decodeQueueRemoveRequest(raw, path)
return v, true, err
case RequestQueueLike:
v, err := decodeQueueLikeRequest(raw, path)
return v, true, err
case RequestPlaybackVoteSkip:
v, err := decodePlaybackVoteSkipRequest(raw, path)
return v, true, err
case RequestSettingsSetPlayMode:
v, err := decodeSettingsSetPlayModeRequest(raw, path)
return v, true, err
case RequestChatSend:
v, err := decodeChatSendRequest(raw, path)
return v, true, err
case RequestSearchTracks:
v, err := decodeSearchTracksRequest(raw, path)
return v, true, err
case RequestSearchPlaylists:
v, err := decodeSearchPlaylistsRequest(raw, path)
return v, true, err
case RequestPlaylistTracks:
v, err := decodePlaylistTracksRequest(raw, path)
return v, true, err
case RequestRecommendList:
v, err := decodeRecommendListRequest(raw, path)
return v, true, err
}
return nil, false, nil
}
func DecodeCreateMemberRequest(body []byte) (CreateMemberRequest, error) {
return decodeCreateMemberRequest(body, "")
}
func DecodeUpdateMeRequest(body []byte) (UpdateMeRequest, error) {
return decodeUpdateMeRequest(body, "")
}
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
return decodeCreateRoomRequest(body, "")
}
func DecodeCreateTicketRequest(body []byte) (CreateTicketRequest, error) {
return decodeCreateTicketRequest(body, "")
}
func DecodeAdminCreateRoomRequest(body []byte) (AdminCreateRoomRequest, error) {
return decodeAdminCreateRoomRequest(body, "")
}
func DecodeAdminUpdateRoomRequest(body []byte) (AdminUpdateRoomRequest, error) {
return decodeAdminUpdateRoomRequest(body, "")
}
func DecodeAdminResetRoomApiKeyRequest(body []byte) (AdminResetRoomApiKeyRequest, error) {
return decodeAdminResetRoomApiKeyRequest(body, "")
}
func DecodeAdminStartNeteaseLoginRequest(body []byte) (AdminStartNeteaseLoginRequest, error) {
return decodeAdminStartNeteaseLoginRequest(body, "")
}
func DecodeAdminCheckNeteaseLoginRequest(body []byte) (AdminCheckNeteaseLoginRequest, error) {
return decodeAdminCheckNeteaseLoginRequest(body, "")
}
func DecodeAdminStartQQLoginRequest(body []byte) (AdminStartQQLoginRequest, error) {
return decodeAdminStartQQLoginRequest(body, "")
}
func DecodeAdminCheckQQLoginRequest(body []byte) (AdminCheckQQLoginRequest, error) {
return decodeAdminCheckQQLoginRequest(body, "")
}
func DecodeAdminStartBilibiliLoginRequest(body []byte) (AdminStartBilibiliLoginRequest, error) {
return decodeAdminStartBilibiliLoginRequest(body, "")
}
func DecodeAdminCheckBilibiliLoginRequest(body []byte) (AdminCheckBilibiliLoginRequest, error) {
return decodeAdminCheckBilibiliLoginRequest(body, "")
}
func DecodeAdminSetYoutubeCredentialRequest(body []byte) (AdminSetYoutubeCredentialRequest, error) {
return decodeAdminSetYoutubeCredentialRequest(body, "")
}
func DecodeAdminUpdateSiteRequest(body []byte) (AdminUpdateSiteRequest, error) {
return decodeAdminUpdateSiteRequest(body, "")
}
func decodeAdminCheckBilibiliLoginRequest(raw json.RawMessage, path string) (AdminCheckBilibiliLoginRequest, error) {
var v AdminCheckBilibiliLoginRequest
m, err := decodeObject(raw, path, "key")
if err != nil {
return v, err
}
if err = requireFields(m, path, "key"); err != nil {
return v, err
}
if v.Key, err = decodeString(m["key"], field(path, "key")); err != nil {
return v, err
}
if err = checkMinLength(v.Key, field(path, "key"), 1); err != nil {
return v, err
}
return v, nil
}
func decodeAdminCheckNeteaseLoginRequest(raw json.RawMessage, path string) (AdminCheckNeteaseLoginRequest, error) {
var v AdminCheckNeteaseLoginRequest
m, err := decodeObject(raw, path, "key")
if err != nil {
return v, err
}
if err = requireFields(m, path, "key"); err != nil {
return v, err
}
if v.Key, err = decodeString(m["key"], field(path, "key")); err != nil {
return v, err
}
if err = checkMinLength(v.Key, field(path, "key"), 1); err != nil {
return v, err
}
return v, nil
}
func decodeAdminCheckQQLoginRequest(raw json.RawMessage, path string) (AdminCheckQQLoginRequest, error) {
var v AdminCheckQQLoginRequest
m, err := decodeObject(raw, path, "key", "type")
if err != nil {
return v, err
}
if err = requireFields(m, path, "key", "type"); err != nil {
return v, err
}
if v.Key, err = decodeString(m["key"], field(path, "key")); err != nil {
return v, err
}
if err = checkMinLength(v.Key, field(path, "key"), 1); err != nil {
return v, err
}
if v.Type, err = decodeQQLoginType(m["type"], field(path, "type")); err != nil {
return v, err
}
return v, nil
}
func decodeAdminCreateRoomRequest(raw json.RawMessage, path string) (AdminCreateRoomRequest, error) {
var v AdminCreateRoomRequest
m, err := decodeObject(raw, path, "description", "name", "password", "slug")
if err != nil {
return v, err
}
if err = requireFields(m, path, "description", "name"); err != nil {
return v, err
}
if v.Description, err = decodeString(m["description"], field(path, "description")); err != nil {
return v, err
}
if err = checkMaxLength(v.Description, field(path, "description"), 500); err != nil {
return v, err
}
if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil {
return v, err
}
if err = checkMinLength(v.Name, field(path, "name"), 1); err != nil {
return v, err
}
if err = checkMaxLength(v.Name, field(path, "name"), 80); err != nil {
return v, err
}
if r, ok := m["password"]; ok {
x, err := decodeString(r, field(path, "password"))
if err != nil {
return v, err
}
v.Password = &x
}
if r, ok := m["slug"]; ok {
x, err := decodeString(r, field(path, "slug"))
if err != nil {
return v, err
}
if err = checkPattern(x, field(path, "slug"), patternAdminCreateRoomRequestSlug); err != nil {
return v, err
}
v.Slug = &x
}
return v, nil
}
func decodeAdminResetRoomApiKeyRequest(raw json.RawMessage, path string) (AdminResetRoomApiKeyRequest, error) {
var v AdminResetRoomApiKeyRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeAdminSetYoutubeCredentialRequest(raw json.RawMessage, path string) (AdminSetYoutubeCredentialRequest, error) {
var v AdminSetYoutubeCredentialRequest
m, err := decodeObject(raw, path, "cookies")
if err != nil {
return v, err
}
if err = requireFields(m, path, "cookies"); err != nil {
return v, err
}
if v.Cookies, err = decodeString(m["cookies"], field(path, "cookies")); err != nil {
return v, err
}
if err = checkMinLength(v.Cookies, field(path, "cookies"), 1); err != nil {
return v, err
}
return v, nil
}
func decodeAdminStartBilibiliLoginRequest(raw json.RawMessage, path string) (AdminStartBilibiliLoginRequest, error) {
var v AdminStartBilibiliLoginRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeAdminStartNeteaseLoginRequest(raw json.RawMessage, path string) (AdminStartNeteaseLoginRequest, error) {
var v AdminStartNeteaseLoginRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeAdminStartQQLoginRequest(raw json.RawMessage, path string) (AdminStartQQLoginRequest, error) {
var v AdminStartQQLoginRequest
m, err := decodeObject(raw, path, "type")
if err != nil {
return v, err
}
if err = requireFields(m, path, "type"); err != nil {
return v, err
}
if v.Type, err = decodeQQLoginType(m["type"], field(path, "type")); err != nil {
return v, err
}
return v, nil
}
func decodeAdminUpdateRoomRequest(raw json.RawMessage, path string) (AdminUpdateRoomRequest, error) {
var v AdminUpdateRoomRequest
m, err := decodeObject(raw, path, "description", "name", "password", "slug")
if err != nil {
return v, err
}
if r, ok := m["description"]; ok {
x, err := decodeString(r, field(path, "description"))
if err != nil {
return v, err
}
if err = checkMaxLength(x, field(path, "description"), 500); err != nil {
return v, err
}
v.Description = &x
}
if r, ok := m["name"]; ok {
x, err := decodeString(r, field(path, "name"))
if err != nil {
return v, err
}
if err = checkMinLength(x, field(path, "name"), 1); err != nil {
return v, err
}
if err = checkMaxLength(x, field(path, "name"), 80); err != nil {
return v, err
}
v.Name = &x
}
if r, ok := m["password"]; ok {
x, err := decodeString(r, field(path, "password"))
if err != nil {
return v, err
}
v.Password = &x
}
if r, ok := m["slug"]; ok {
x, err := decodeString(r, field(path, "slug"))
if err != nil {
return v, err
}
if err = checkPattern(x, field(path, "slug"), patternAdminUpdateRoomRequestSlug); err != nil {
return v, err
}
v.Slug = &x
}
return v, nil
}
func decodeAdminUpdateSiteRequest(raw json.RawMessage, path string) (AdminUpdateSiteRequest, error) {
var v AdminUpdateSiteRequest
m, err := decodeObject(raw, path, "brandName")
if err != nil {
return v, err
}
if err = requireFields(m, path, "brandName"); err != nil {
return v, err
}
if v.BrandName, err = decodeString(m["brandName"], field(path, "brandName")); 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")
if err != nil {
return v, err
}
if err = requireFields(m, path, "content"); err != nil {
return v, err
}
if v.Content, err = decodeString(m["content"], field(path, "content")); 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 decodeCreateRoomRequest(raw json.RawMessage, path string) (CreateRoomRequest, error) {
var v CreateRoomRequest
m, err := decodeObject(raw, path, "description", "name", "password")
if err != nil {
return v, err
}
if err = requireFields(m, path, "description", "name"); err != nil {
return v, err
}
if v.Description, err = decodeString(m["description"], field(path, "description")); err != nil {
return v, err
}
if err = checkMaxLength(v.Description, field(path, "description"), 500); err != nil {
return v, err
}
if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil {
return v, err
}
if err = checkMinLength(v.Name, field(path, "name"), 1); err != nil {
return v, err
}
if err = checkMaxLength(v.Name, field(path, "name"), 80); err != nil {
return v, err
}
if r, ok := m["password"]; ok {
x, err := decodeString(r, field(path, "password"))
if err != nil {
return v, err
}
v.Password = &x
}
return v, nil
}
func decodeCreateTicketRequest(raw json.RawMessage, path string) (CreateTicketRequest, error) {
var v CreateTicketRequest
m, err := decodeObject(raw, path, "password")
if err != nil {
return v, err
}
if r, ok := m["password"]; ok {
x, err := decodeString(r, field(path, "password"))
if err != nil {
return v, err
}
v.Password = &x
}
return v, nil
}
func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error) {
var v HelloRequest
m, err := decodeObject(raw, path, "protocolVersion", "roomId", "ticket")
if err != nil {
return v, err
}
if err = requireFields(m, path, "protocolVersion", "roomId", "ticket"); err != nil {
return v, err
}
if v.ProtocolVersion, err = decodeString(m["protocolVersion"], field(path, "protocolVersion")); err != nil {
return v, err
}
if v.RoomID, err = decodeString(m["roomId"], field(path, "roomId")); err != nil {
return v, err
}
if v.Ticket, err = decodeString(m["ticket"], field(path, "ticket")); 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")
if err != nil {
return v, err
}
if err = requireFields(m, path, "clientTimeMs"); err != nil {
return v, err
}
if v.ClientTimeMs, err = decodeInteger(m["clientTimeMs"], field(path, "clientTimeMs")); err != nil {
return v, err
}
return v, nil
}
func decodePlayMode(raw json.RawMessage, path string) (PlayMode, error) {
s, err := decodeString(raw, path)
if err != nil {
return "", err
}
switch v := PlayMode(s); v {
case PlayModeSequential, PlayModeRandom, PlayModeRotation:
return v, nil
}
return "", invalid(path, "取值应为 sequential、random、rotation 之一")
}
func decodePlaybackVoteSkipRequest(raw json.RawMessage, path string) (PlaybackVoteSkipRequest, error) {
var v PlaybackVoteSkipRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodePlaylistTracksRequest(raw json.RawMessage, path string) (PlaylistTracksRequest, error) {
var v PlaylistTracksRequest
m, err := decodeObject(raw, path, "playlistId", "source")
if err != nil {
return v, err
}
if err = requireFields(m, path, "playlistId", "source"); err != nil {
return v, err
}
if v.PlaylistID, err = decodeString(m["playlistId"], field(path, "playlistId")); err != nil {
return v, err
}
if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil {
return v, err
}
return v, nil
}
func decodeQQLoginType(raw json.RawMessage, path string) (QQLoginType, error) {
s, err := decodeString(raw, path)
if err != nil {
return "", err
}
switch v := QQLoginType(s); v {
case QQLoginTypeQQ, QQLoginTypeWx:
return v, nil
}
return "", invalid(path, "取值应为 qq、wx 之一")
}
func decodeQueueAddRequest(raw json.RawMessage, path string) (QueueAddRequest, error) {
var v QueueAddRequest
m, err := decodeObject(raw, path, "keyword", "requester", "source", "track")
if err != nil {
return v, err
}
if err = checkOneOf(m, path, "只能提供以下字段组合之一:track;source、keyword", []string{"requester"}, []string{"track"}, []string{"source", "keyword"}); err != nil {
return v, err
}
if r, ok := m["keyword"]; ok {
x, err := decodeString(r, field(path, "keyword"))
if err != nil {
return v, err
}
if err = checkMinLength(x, field(path, "keyword"), 1); err != nil {
return v, err
}
if err = checkMaxLength(x, field(path, "keyword"), 500); err != nil {
return v, err
}
v.Keyword = &x
}
if r, ok := m["requester"]; ok {
x, err := decodeString(r, field(path, "requester"))
if err != nil {
return v, err
}
if err = checkMinLength(x, field(path, "requester"), 1); err != nil {
return v, err
}
if err = checkMaxLength(x, field(path, "requester"), 80); err != nil {
return v, err
}
v.Requester = &x
}
if r, ok := m["source"]; ok {
x, err := decodeSource(r, field(path, "source"))
if err != nil {
return v, err
}
v.Source = &x
}
if r, ok := m["track"]; ok {
x, err := decodeTrack(r, field(path, "track"))
if err != nil {
return v, err
}
v.Track = &x
}
return v, nil
}
func decodeQueueLikeRequest(raw json.RawMessage, path string) (QueueLikeRequest, error) {
var v QueueLikeRequest
m, err := decodeObject(raw, path, "entryId")
if err != nil {
return v, err
}
if err = requireFields(m, path, "entryId"); err != nil {
return v, err
}
if v.EntryID, err = decodeString(m["entryId"], field(path, "entryId")); err != nil {
return v, err
}
return v, nil
}
func decodeQueueRemoveRequest(raw json.RawMessage, path string) (QueueRemoveRequest, error) {
var v QueueRemoveRequest
m, err := decodeObject(raw, path, "entryId")
if err != nil {
return v, err
}
if err = requireFields(m, path, "entryId"); err != nil {
return v, err
}
if v.EntryID, err = decodeString(m["entryId"], field(path, "entryId")); err != nil {
return v, err
}
return v, nil
}
func decodeRecommendListRequest(raw json.RawMessage, path string) (RecommendListRequest, error) {
var v RecommendListRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
var v RoomSyncRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeSearchPlaylistsRequest(raw json.RawMessage, path string) (SearchPlaylistsRequest, error) {
var v SearchPlaylistsRequest
m, err := decodeObject(raw, path, "keyword", "page", "pageSize", "source")
if err != nil {
return v, err
}
if err = requireFields(m, path, "keyword", "page", "pageSize", "source"); err != nil {
return v, err
}
if v.Keyword, err = decodeString(m["keyword"], field(path, "keyword")); err != nil {
return v, err
}
if err = checkMinLength(v.Keyword, field(path, "keyword"), 1); err != nil {
return v, err
}
if err = checkMaxLength(v.Keyword, field(path, "keyword"), 500); err != nil {
return v, err
}
if v.Page, err = decodeInteger(m["page"], field(path, "page")); err != nil {
return v, err
}
if err = checkMinimum(v.Page, field(path, "page"), 1); err != nil {
return v, err
}
if v.PageSize, err = decodeInteger(m["pageSize"], field(path, "pageSize")); err != nil {
return v, err
}
if err = checkMinimum(v.PageSize, field(path, "pageSize"), 1); err != nil {
return v, err
}
if err = checkMaximum(v.PageSize, field(path, "pageSize"), 100); err != nil {
return v, err
}
if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil {
return v, err
}
return v, nil
}
func decodeSearchTracksRequest(raw json.RawMessage, path string) (SearchTracksRequest, error) {
var v SearchTracksRequest
m, err := decodeObject(raw, path, "keyword", "page", "pageSize", "source")
if err != nil {
return v, err
}
if err = requireFields(m, path, "keyword", "page", "pageSize", "source"); err != nil {
return v, err
}
if v.Keyword, err = decodeString(m["keyword"], field(path, "keyword")); err != nil {
return v, err
}
if err = checkMinLength(v.Keyword, field(path, "keyword"), 1); err != nil {
return v, err
}
if err = checkMaxLength(v.Keyword, field(path, "keyword"), 500); err != nil {
return v, err
}
if v.Page, err = decodeInteger(m["page"], field(path, "page")); err != nil {
return v, err
}
if err = checkMinimum(v.Page, field(path, "page"), 1); err != nil {
return v, err
}
if v.PageSize, err = decodeInteger(m["pageSize"], field(path, "pageSize")); err != nil {
return v, err
}
if err = checkMinimum(v.PageSize, field(path, "pageSize"), 1); err != nil {
return v, err
}
if err = checkMaximum(v.PageSize, field(path, "pageSize"), 100); err != nil {
return v, err
}
if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil {
return v, err
}
return v, nil
}
func decodeSettingsSetPlayModeRequest(raw json.RawMessage, path string) (SettingsSetPlayModeRequest, error) {
var v SettingsSetPlayModeRequest
m, err := decodeObject(raw, path, "playMode")
if err != nil {
return v, err
}
if err = requireFields(m, path, "playMode"); err != nil {
return v, err
}
if v.PlayMode, err = decodePlayMode(m["playMode"], field(path, "playMode")); err != nil {
return v, err
}
return v, nil
}
func decodeSource(raw json.RawMessage, path string) (Source, error) {
s, err := decodeString(raw, path)
if err != nil {
return "", err
}
switch v := Source(s); v {
case SourceNetease, SourceQQ, SourceBilibili, SourceLink:
return v, nil
}
return "", invalid(path, "取值应为 netease、qq、bilibili、link 之一")
}
func decodeTrack(raw json.RawMessage, path string) (Track, error) {
var v Track
m, err := decodeObject(raw, path, "id", "source")
if err != nil {
return v, err
}
if err = requireFields(m, path, "id", "source"); err != nil {
return v, err
}
if v.ID, err = decodeString(m["id"], field(path, "id")); err != nil {
return v, err
}
if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil {
return v, err
}
if v.Source == SourceLink {
if err = checkMaxBytes(v.ID, field(path, "id"), 2048); err != nil {
return v, err
}
if err = checkURL(v.ID, field(path, "id")); err != nil {
return v, err
}
}
if v.Source == SourceBilibili {
if err = checkPattern(v.ID, field(path, "id"), patternTrackIDWhenBilibili); err != nil {
return v, err
}
}
return v, nil
}
func decodeUpdateMeRequest(raw json.RawMessage, path string) (UpdateMeRequest, error) {
var v UpdateMeRequest
m, err := decodeObject(raw, path, "email", "name")
if err != nil {
return v, err
}
if r, ok := m["email"]; ok {
x, err := decodeString(r, field(path, "email"))
if err != nil {
return v, err
}
if err = checkMaxLength(x, field(path, "email"), 320); err != nil {
return v, err
}
v.Email = &x
}
if r, ok := m["name"]; ok {
x, err := decodeString(r, field(path, "name"))
if err != nil {
return v, err
}
if err = checkMinLength(x, field(path, "name"), 1); err != nil {
return v, err
}
if err = checkMaxLength(x, field(path, "name"), 80); err != nil {
return v, err
}
v.Name = &x
}
return v, nil
}
+241
View File
@@ -0,0 +1,241 @@
package protocol
import (
"bytes"
"encoding/json"
"fmt"
"maps"
"net/netip"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"unicode/utf8"
)
// ArgumentError 的 Error() 原样作为 INVALID_ARGUMENT 的 message 返回给客户端。
type ArgumentError struct {
Path string
Reason string
}
func (e *ArgumentError) Error() string {
if e.Path == "" {
return e.Reason
}
return e.Path + ":" + e.Reason
}
func invalid(path, reason string) error {
return &ArgumentError{Path: path, Reason: reason}
}
func field(path, name string) string {
if path == "" {
return name
}
return path + "." + name
}
var null = []byte("null")
func decodeObject(raw json.RawMessage, path string, known ...string) (map[string]json.RawMessage, error) {
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) != nil || m == nil {
return nil, invalid(path, "应为 JSON 对象")
}
return m, rejectUnknown(m, path, known...)
}
func rejectUnknown(m map[string]json.RawMessage, path string, known ...string) error {
// 按名称顺序检查,有多个未知字段时报告的总是同一个。
for _, name := range slices.Sorted(maps.Keys(m)) {
if !slices.Contains(known, name) {
return invalid(field(path, name), "未知字段")
}
}
return nil
}
func requireFields(m map[string]json.RawMessage, path string, names ...string) error {
for _, name := range names {
if _, ok := m[name]; !ok {
return invalid(field(path, name), "缺少必填字段")
}
}
return nil
}
// encoding/json 把 null 解码为零值而不报错,所以 null 要单独拒绝。
func decodeString(raw json.RawMessage, path string) (string, error) {
var s string
if bytes.Equal(raw, null) || json.Unmarshal(raw, &s) != nil {
return "", invalid(path, "应为字符串")
}
// PostgreSQL 的 text 无法存储 U+0000,含有它的文本一旦进入落盘批次就会让落盘失败。
if strings.ContainsRune(s, 0) {
return "", invalid(path, "不能包含 U+0000")
}
return s, nil
}
func decodeInteger(raw json.RawMessage, path string) (int64, error) {
var n int64
if bytes.Equal(raw, null) || json.Unmarshal(raw, &n) != nil {
return 0, invalid(path, "应为整数")
}
return n, nil
}
func checkMinLength(s, path string, n int) error {
if utf8.RuneCountInString(s) < n {
return invalid(path, fmt.Sprintf("长度不能少于 %d 个字符", n))
}
return nil
}
func checkMaxLength(s, path string, n int) error {
if utf8.RuneCountInString(s) > n {
return invalid(path, fmt.Sprintf("长度不能超过 %d 个字符", n))
}
return nil
}
func checkMaxBytes(s, path string, n int) error {
if len(s) > n {
return invalid(path, fmt.Sprintf("不能超过 %d 字节", n))
}
return nil
}
func checkMinimum(v int64, path string, n int64) error {
if v < n {
return invalid(path, fmt.Sprintf("不能小于 %d", n))
}
return nil
}
func checkMaximum(v int64, path string, n int64) error {
if v > n {
return invalid(path, fmt.Sprintf("不能大于 %d", n))
}
return nil
}
func checkPattern(s, path string, re *regexp.Regexp) error {
if !re.MatchString(s) {
return invalid(path, "格式不正确")
}
return nil
}
func checkURL(s, path string) error {
if !validURL(s) {
return invalid(path, "应为 http 或 https 的链接")
}
return nil
}
var (
bracketedHost = regexp.MustCompile(`^\[[0-9A-Fa-f:.]+\]$`)
numericLabel = regexp.MustCompile(`^(?i:[0-9]+|0x[0-9a-f]*)$`)
)
// x-url:scheme 为 http 或 https(不区分大小写)的绝对地址。浏览器的 new URL() 与 url.Parse 对一些写法的判断不同,
// 网页生成的校验函数按同样的规则检查,两边的结果才一致(protocol/testdata 中的用例由两边的测试共用):
// - 不含控制字符、空格和反斜杠(浏览器把 \ 当作 /),不含用户信息;
// - 主机名只由字母、数字、-、.、_、~ 和非 ASCII 字符组成,或者是方括号中的 IPv6 地址(浏览器会解码主机名中的 % 编码);
// - 最后一段是数字(或以 0x 开头)的主机名会被浏览器按 IPv4 地址解析,所以必须是规范的 IPv4 地址;
// - 端口不超过 65535,路径和片段中的 % 后面必须是两位十六进制数(url.Parse 不接受其他写法,浏览器却照样接受)。
//
// 国际化域名(主机名含非 ASCII 字符,或者有以 xn-- 开头的标签)只由浏览器检查:不合法时浏览器拒绝而这里接受,到准备时才会下载失败。
func validURL(s string) bool {
if strings.ContainsFunc(s, func(r rune) bool { return r <= ' ' || r == 0x7f || r == '\\' }) {
return false
}
scheme, rest, ok := strings.Cut(s, "://")
if !ok || !strings.EqualFold(scheme, "http") && !strings.EqualFold(scheme, "https") {
return false
}
authority, tail := rest, ""
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
authority, tail = rest[:i], rest[i:]
}
host, port := authority, ""
if strings.HasPrefix(authority, "[") {
end := strings.IndexByte(authority, ']') + 1
host = authority[:end]
if end == 0 || !bracketedHost.MatchString(host) {
return false
}
if after := authority[end:]; after != "" {
if port, ok = strings.CutPrefix(after, ":"); !ok {
return false
}
}
} else {
if h, p, ok := strings.Cut(authority, ":"); ok {
host, port = h, p
}
if host == "" || strings.ContainsFunc(host, func(r rune) bool { return r < utf8.RuneSelf && !hostChar(r) }) {
return false
}
labels := strings.Split(strings.TrimSuffix(host, "."), ".")
if numericLabel.MatchString(labels[len(labels)-1]) {
if a, err := netip.ParseAddr(host); err != nil || !a.Is4() {
return false
}
}
}
if strings.Trim(port, "0123456789") != "" {
return false
}
if n, err := strconv.Atoi(port); port != "" && (err != nil || n > 65535) {
return false
}
path, fragment, _ := strings.Cut(tail, "#")
path, _, _ = strings.Cut(path, "?")
if !validEscapes(path) || !validEscapes(fragment) {
return false
}
u, err := url.Parse(s)
return err == nil && u.Hostname() != ""
}
func hostChar(r rune) bool {
return 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z' || '0' <= r && r <= '9' || strings.ContainsRune("-._~", r)
}
func validEscapes(s string) bool {
for {
i := strings.IndexByte(s, '%')
if i < 0 {
return true
}
if i+2 >= len(s) || !isHex(s[i+1]) || !isHex(s[i+2]) {
return false
}
s = s[i+3:]
}
}
func isHex(c byte) bool {
return '0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F'
}
// IDL 中 oneOf 的每个分支都用 additionalProperties: false 限定了字段,所以除去各分支共用的可选字段,出现的字段必须恰好是其中一组。
func checkOneOf(m map[string]json.RawMessage, path, reason string, optional []string, groups ...[]string) error {
n := len(m)
for _, name := range optional {
if _, ok := m[name]; ok {
n--
}
}
for _, group := range groups {
if n == len(group) && requireFields(m, path, group...) == nil {
return nil
}
}
return invalid(path, reason)
}
+94
View File
@@ -0,0 +1,94 @@
package protocol
import (
"encoding/json"
"maps"
"net/url"
"slices"
"strconv"
)
type Auth uint8
const (
AuthNone Auth = iota
AuthMember
AuthTicket
AuthAdmin
)
type Route struct {
Pattern string
Auth Auth
Status int
Location bool
}
// 房间内请求的 HTTP 绑定。路径参数是请求中同名的字段;GET 和 DELETE 的其余字段来自查询串,其他方法的来自 JSON 请求体。
// 组装出的请求交给与 WebSocket 相同的解码函数校验。
type RoomRoute struct {
Route
// WebSocket 的请求类型。
Type string
params []string
query map[string]queryKind
body bool
}
type queryKind uint8
const (
queryString queryKind = iota
queryInteger
)
// 空的请求体按 {} 处理:没有字段的请求可以不带请求体。
func (r RoomRoute) Decode(pathValue func(string) string, rawQuery string, body []byte) (any, error) {
query, err := url.ParseQuery(rawQuery)
if err != nil {
return nil, invalid("", "查询串格式不正确")
}
m := map[string]json.RawMessage{}
for _, name := range slices.Sorted(maps.Keys(query)) {
kind, ok := r.query[name]
switch {
case !ok:
return nil, invalid(name, "未知参数")
case len(query[name]) > 1:
return nil, invalid(name, "参数重复")
case kind == queryInteger:
n, err := strconv.ParseInt(query.Get(name), 10, 64)
if err != nil {
return nil, invalid(name, "应为整数")
}
m[name] = json.RawMessage(strconv.FormatInt(n, 10))
default:
m[name] = jsonString(query.Get(name))
}
}
if r.body && len(body) > 0 {
if json.Unmarshal(body, &m) != nil || m == nil {
return nil, invalid("", "应为 JSON 对象")
}
}
for _, name := range r.params {
if _, ok := m[name]; ok {
return nil, invalid(name, "未知字段")
}
m[name] = jsonString(pathValue(name))
}
raw, err := json.Marshal(m)
if err != nil {
return nil, err
}
data, _, err := decodeRoomRequest(r.Type, raw, "")
if err != nil {
return nil, err
}
return data, nil
}
func jsonString(s string) json.RawMessage {
b, _ := json.Marshal(s)
return b
}
+1100
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+669
View File
@@ -0,0 +1,669 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
package protocol
import "encoding/json"
const Version = "09dbf7ea495db969"
type APIKey struct {
Key string `json:"key"`
}
type ActivityKind string
const (
ActivityKindAdded ActivityKind = "added"
ActivityKindRemoved ActivityKind = "removed"
ActivityKindLiked ActivityKind = "liked"
ActivityKindVoted ActivityKind = "voted"
ActivityKindSkipped ActivityKind = "skipped"
ActivityKindFailed ActivityKind = "failed"
)
type AdminCheckBilibiliLoginRequest struct {
Key string `json:"key"`
}
type AdminCheckNeteaseLoginRequest struct {
Key string `json:"key"`
}
type AdminCheckQQLoginRequest struct {
Key string `json:"key"`
Type QQLoginType `json:"type"`
}
type AdminCreateRoomRequest struct {
Description string `json:"description"`
Name string `json:"name"`
Password *string `json:"password,omitempty"`
Slug *string `json:"slug,omitempty"`
}
type AdminCreateRoomResponse struct {
ID string `json:"id"`
}
type AdminGetBilibiliCredentialResponse struct {
Account *SourceAccount `json:"account"`
Set bool `json:"set"`
}
type AdminGetNeteaseCredentialResponse struct {
Account *SourceAccount `json:"account"`
Set bool `json:"set"`
}
type AdminGetQQCredentialResponse struct {
Account *SourceAccount `json:"account"`
Set bool `json:"set"`
}
type AdminGetRoomApiKeyResponse struct {
CreatedAtMs int64 `json:"createdAtMs"`
Set bool `json:"set"`
}
type AdminGetYoutubeCredentialResponse struct {
Set bool `json:"set"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AdminListMediaResponse struct {
Items []StoredMedia `json:"items"`
}
func (v AdminListMediaResponse) MarshalJSON() ([]byte, error) {
type plain AdminListMediaResponse
if v.Items == nil {
v.Items = []StoredMedia{}
}
return json.Marshal(plain(v))
}
type AdminListRoomsResponse struct {
Items []RoomListing `json:"items"`
}
func (v AdminListRoomsResponse) MarshalJSON() ([]byte, error) {
type plain AdminListRoomsResponse
if v.Items == nil {
v.Items = []RoomListing{}
}
return json.Marshal(plain(v))
}
type AdminResetRoomApiKeyRequest struct{}
type AdminSetYoutubeCredentialRequest struct {
Cookies string `json:"cookies"`
}
type AdminStartBilibiliLoginRequest struct{}
type AdminStartNeteaseLoginRequest struct{}
type AdminStartQQLoginRequest struct {
Type QQLoginType `json:"type"`
}
type AdminUpdateRoomRequest struct {
Description *string `json:"description,omitempty"`
Name *string `json:"name,omitempty"`
Password *string `json:"password,omitempty"`
Slug *string `json:"slug,omitempty"`
}
type AdminUpdateSiteRequest struct {
BrandName string `json:"brandName"`
}
type AdminUpdateSiteResponse struct {
BrandName string `json:"brandName"`
}
type ChatMessageEvent struct {
Content string `json:"content"`
Member Member `json:"member"`
SentAtMs int64 `json:"sentAtMs"`
}
type ChatSendRequest struct {
Content string `json:"content"`
}
type CreateMemberRequest struct{}
type CreateMemberResponse struct {
Member Member `json:"member"`
MemberToken string `json:"memberToken"`
}
type CreateRoomRequest struct {
Description string `json:"description"`
Name string `json:"name"`
Password *string `json:"password,omitempty"`
}
type CreateRoomResponse struct {
ID string `json:"id"`
}
type CreateTicketRequest struct {
Password *string `json:"password,omitempty"`
}
type CreateTicketResponse struct {
Ticket string `json:"ticket"`
}
type Empty struct{}
type Entry struct {
Auto bool `json:"auto"`
By *Member `json:"by"`
ID string `json:"id"`
Likes int64 `json:"likes"`
Meta TrackMeta `json:"meta"`
Status EntryStatus `json:"status"`
Track Track `json:"track"`
}
type EntryStatus string
const (
EntryStatusPreparing EntryStatus = "preparing"
EntryStatusReady EntryStatus = "ready"
)
type ErrorCode string
const (
ErrorCodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED"
ErrorCodeForbidden ErrorCode = "FORBIDDEN"
ErrorCodeNotFound ErrorCode = "NOT_FOUND"
ErrorCodeConflict ErrorCode = "CONFLICT"
ErrorCodeQueueFull ErrorCode = "QUEUE_FULL"
ErrorCodeNothingPlaying ErrorCode = "NOTHING_PLAYING"
ErrorCodeRateLimited ErrorCode = "RATE_LIMITED"
ErrorCodeProviderError ErrorCode = "PROVIDER_ERROR"
ErrorCodeUnavailable ErrorCode = "UNAVAILABLE"
ErrorCodeClientOutdated ErrorCode = "CLIENT_OUTDATED"
ErrorCodeInternal ErrorCode = "INTERNAL"
)
type ErrorInfo struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
}
type GetMeResponse struct {
Member Member `json:"member"`
}
type GetSiteResponse struct {
BrandName string `json:"brandName"`
}
type HelloRequest struct {
ProtocolVersion string `json:"protocolVersion"`
RoomID string `json:"roomId"`
Ticket string `json:"ticket"`
}
type HelloResponse struct {
Member Member `json:"member"`
Room RoomSnapshot `json:"room"`
ServerTimeMs int64 `json:"serverTimeMs"`
}
type ListRoomsResponse struct {
Items []RoomListing `json:"items"`
}
func (v ListRoomsResponse) MarshalJSON() ([]byte, error) {
type plain ListRoomsResponse
if v.Items == nil {
v.Items = []RoomListing{}
}
return json.Marshal(plain(v))
}
type Member struct {
AvatarHash string `json:"avatarHash"`
ID string `json:"id"`
Name string `json:"name"`
}
type Members struct {
OnlineCount int64 `json:"onlineCount"`
Sessions []Member `json:"sessions"`
}
func (v Members) MarshalJSON() ([]byte, error) {
type plain Members
if v.Sessions == nil {
v.Sessions = []Member{}
}
return json.Marshal(plain(v))
}
type NowPlaying struct {
DurationMs int64 `json:"durationMs"`
Entry Entry `json:"entry"`
Gain float64 `json:"gain"`
Lyric string `json:"lyric"`
MediaURL string `json:"mediaUrl"`
StartedAtMs int64 `json:"startedAtMs"`
}
type PingRequest struct {
ClientTimeMs int64 `json:"clientTimeMs"`
}
type PingResponse struct {
ClientTimeMs int64 `json:"clientTimeMs"`
ServerTimeMs int64 `json:"serverTimeMs"`
}
type PlayMode string
const (
PlayModeSequential PlayMode = "sequential"
PlayModeRandom PlayMode = "random"
PlayModeRotation PlayMode = "rotation"
)
type PlaybackUpdatedEvent struct {
NowPlaying *NowPlaying `json:"nowPlaying"`
}
type PlaybackVoteSkipRequest struct{}
type PlaybackVoteSkipResponse struct {
Required int64 `json:"required"`
Skipped bool `json:"skipped"`
Votes int64 `json:"votes"`
}
type PlaylistSummary struct {
CoverURL string `json:"coverUrl"`
Creator string `json:"creator"`
Description string `json:"description"`
ID string `json:"id"`
Name string `json:"name"`
PlayCount int64 `json:"playCount"`
Source Source `json:"source"`
TrackCount int64 `json:"trackCount"`
}
type PlaylistTracksRequest struct {
PlaylistID string `json:"playlistId"`
Source Source `json:"source"`
}
type PlaylistTracksResponse struct {
Items []TrackSummary `json:"items"`
Total int64 `json:"total"`
}
func (v PlaylistTracksResponse) MarshalJSON() ([]byte, error) {
type plain PlaylistTracksResponse
if v.Items == nil {
v.Items = []TrackSummary{}
}
return json.Marshal(plain(v))
}
type QQLoginType string
const (
QQLoginTypeQQ QQLoginType = "qq"
QQLoginTypeWx QQLoginType = "wx"
)
type QRLogin struct {
ExpiresAtMs int64 `json:"expiresAtMs"`
Key string `json:"key"`
QrImage string `json:"qrImage"`
}
type QRLoginResult struct {
Message string `json:"message"`
Status QRLoginStatus `json:"status"`
}
type QRLoginStatus string
const (
QRLoginStatusWaiting QRLoginStatus = "waiting"
QRLoginStatusScanned QRLoginStatus = "scanned"
QRLoginStatusSuccess QRLoginStatus = "success"
QRLoginStatusExpired QRLoginStatus = "expired"
QRLoginStatusFailed QRLoginStatus = "failed"
)
type QueueAddRequest struct {
Keyword *string `json:"keyword,omitempty"`
Requester *string `json:"requester,omitempty"`
Source *Source `json:"source,omitempty"`
Track *Track `json:"track,omitempty"`
}
type QueueAddResponse struct {
Entry Entry `json:"entry"`
}
type QueueLikeRequest struct {
EntryID string `json:"entryId"`
}
type QueueLikeResponse struct {
Likes int64 `json:"likes"`
}
type QueueRemoveRequest struct {
EntryID string `json:"entryId"`
}
type QueueUpdatedEvent struct {
Items []Entry `json:"items"`
Pending *Entry `json:"pending"`
}
func (v QueueUpdatedEvent) MarshalJSON() ([]byte, error) {
type plain QueueUpdatedEvent
if v.Items == nil {
v.Items = []Entry{}
}
return json.Marshal(plain(v))
}
type RecommendListRequest struct{}
type RecommendListResponse struct {
Items []TrackSummary `json:"items"`
}
func (v RecommendListResponse) MarshalJSON() ([]byte, error) {
type plain RecommendListResponse
if v.Items == nil {
v.Items = []TrackSummary{}
}
return json.Marshal(plain(v))
}
type RoomActivityEvent struct {
Entry *Entry `json:"entry,omitempty"`
Kind ActivityKind `json:"kind"`
Member *Member `json:"member,omitempty"`
Message string `json:"message"`
Required *int64 `json:"required,omitempty"`
Votes *int64 `json:"votes,omitempty"`
}
type RoomInfo struct {
Description string `json:"description"`
ID string `json:"id"`
Kind RoomKind `json:"kind"`
Name string `json:"name"`
}
type RoomKind string
const (
RoomKindPersistent RoomKind = "persistent"
RoomKindEphemeral RoomKind = "ephemeral"
)
type RoomListing struct {
Description string `json:"description"`
HasPassword bool `json:"hasPassword"`
ID string `json:"id"`
Kind RoomKind `json:"kind"`
Name string `json:"name"`
OnlineCount int64 `json:"onlineCount"`
Slug *string `json:"slug"`
}
type RoomSnapshot struct {
Items []Entry `json:"items"`
Members Members `json:"members"`
NowPlaying *NowPlaying `json:"nowPlaying"`
Pending *Entry `json:"pending"`
PlayMode PlayMode `json:"playMode"`
Room RoomInfo `json:"room"`
Seq int64 `json:"seq"`
Term int64 `json:"term"`
}
func (v RoomSnapshot) MarshalJSON() ([]byte, error) {
type plain RoomSnapshot
if v.Items == nil {
v.Items = []Entry{}
}
return json.Marshal(plain(v))
}
type RoomSyncRequest struct{}
type RoomSyncResponse struct {
Room RoomSnapshot `json:"room"`
}
type RoomUpdatedEvent struct {
Description string `json:"description"`
Name string `json:"name"`
}
type SearchPlaylistsRequest struct {
Keyword string `json:"keyword"`
Page int64 `json:"page"`
PageSize int64 `json:"pageSize"`
Source Source `json:"source"`
}
type SearchPlaylistsResponse struct {
Items []PlaylistSummary `json:"items"`
Total int64 `json:"total"`
}
func (v SearchPlaylistsResponse) MarshalJSON() ([]byte, error) {
type plain SearchPlaylistsResponse
if v.Items == nil {
v.Items = []PlaylistSummary{}
}
return json.Marshal(plain(v))
}
type SearchTracksRequest struct {
Keyword string `json:"keyword"`
Page int64 `json:"page"`
PageSize int64 `json:"pageSize"`
Source Source `json:"source"`
}
type SearchTracksResponse struct {
Items []TrackSummary `json:"items"`
Total int64 `json:"total"`
}
func (v SearchTracksResponse) MarshalJSON() ([]byte, error) {
type plain SearchTracksResponse
if v.Items == nil {
v.Items = []TrackSummary{}
}
return json.Marshal(plain(v))
}
type SettingsSetPlayModeRequest struct {
PlayMode PlayMode `json:"playMode"`
}
type SettingsUpdatedEvent struct {
PlayMode PlayMode `json:"playMode"`
}
type Source string
const (
SourceNetease Source = "netease"
SourceQQ Source = "qq"
SourceBilibili Source = "bilibili"
SourceLink Source = "link"
)
type SourceAccount struct {
ID string `json:"id"`
Name *string `json:"name"`
Verified bool `json:"verified"`
Vip *bool `json:"vip"`
}
type StoredMedia struct {
Key string `json:"key"`
LastPlayedAtMs *int64 `json:"lastPlayedAtMs"`
Link string `json:"link"`
Size int64 `json:"size"`
Source Source `json:"source"`
StoredAtMs int64 `json:"storedAtMs"`
Title string `json:"title"`
}
type Track struct {
ID string `json:"id"`
Source Source `json:"source"`
}
type TrackMeta struct {
Album string `json:"album"`
Artist string `json:"artist"`
CoverURL string `json:"coverUrl"`
DurationMs int64 `json:"durationMs"`
PageURL string `json:"pageUrl"`
Title string `json:"title"`
}
type TrackSummary struct {
Meta TrackMeta `json:"meta"`
Track Track `json:"track"`
}
type UpdateMeRequest struct {
Email *string `json:"email,omitempty"`
Name *string `json:"name,omitempty"`
}
type UpdateMeResponse struct {
Member Member `json:"member"`
}
type ErrorResponse struct {
Error ErrorInfo `json:"error"`
}
const (
RequestHello = "hello"
RequestPing = "ping"
RequestRoomSync = "room.sync"
RequestQueueAdd = "queue.add"
RequestQueueRemove = "queue.remove"
RequestQueueLike = "queue.like"
RequestPlaybackVoteSkip = "playback.voteSkip"
RequestSettingsSetPlayMode = "settings.setPlayMode"
RequestChatSend = "chat.send"
RequestSearchTracks = "search.tracks"
RequestSearchPlaylists = "search.playlists"
RequestPlaylistTracks = "playlist.tracks"
RequestRecommendList = "recommend.list"
)
const (
EventError = "error"
EventPlaybackUpdated = "playback.updated"
EventQueueUpdated = "queue.updated"
EventMembersUpdated = "members.updated"
EventSettingsUpdated = "settings.updated"
EventRoomUpdated = "room.updated"
EventChatMessage = "chat.message"
EventRoomActivity = "room.activity"
)
const (
CloseInvalidArgument = 1008
CloseInternal = 1011
CloseServiceRestart = 1012
CloseUnavailable = 1013
CloseUnauthorized = 4401
CloseNotFound = 4404
CloseHandshakeTimeout = 4408
CloseClientOutdated = 4426
CloseSendQueueFull = 4429
)
var (
RouteCreateMember = Route{Pattern: "POST /api/members", Auth: AuthNone, Status: 201}
RouteGetMe = Route{Pattern: "GET /api/me", Auth: AuthMember, Status: 200}
RouteUpdateMe = Route{Pattern: "PATCH /api/me", Auth: AuthMember, Status: 200}
RouteListRooms = Route{Pattern: "GET /api/rooms", Auth: AuthNone, Status: 200}
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
RouteCreateTicket = Route{Pattern: "POST /api/rooms/{id}/tickets", Auth: AuthMember, Status: 201}
RouteGetSite = Route{Pattern: "GET /api/site", Auth: AuthNone, Status: 200}
RouteAdminListRooms = Route{Pattern: "GET /api/admin/rooms", Auth: AuthAdmin, Status: 200}
RouteAdminCreateRoom = Route{Pattern: "POST /api/admin/rooms", Auth: AuthAdmin, Status: 201, Location: true}
RouteAdminUpdateRoom = Route{Pattern: "PATCH /api/admin/rooms/{id}", Auth: AuthAdmin, Status: 204}
RouteAdminDeleteRoom = Route{Pattern: "DELETE /api/admin/rooms/{id}", Auth: AuthAdmin, Status: 204}
RouteAdminGetRoomApiKey = Route{Pattern: "GET /api/admin/rooms/{id}/api-key", Auth: AuthAdmin, Status: 200}
RouteAdminResetRoomApiKey = Route{Pattern: "POST /api/admin/rooms/{id}/api-key", Auth: AuthAdmin, Status: 201, Location: true}
RouteAdminGetNeteaseCredential = Route{Pattern: "GET /api/admin/credentials/netease", Auth: AuthAdmin, Status: 200}
RouteAdminGetQQCredential = Route{Pattern: "GET /api/admin/credentials/qq", Auth: AuthAdmin, Status: 200}
RouteAdminStartNeteaseLogin = Route{Pattern: "POST /api/admin/credentials/netease/qr-logins", Auth: AuthAdmin, Status: 201}
RouteAdminCheckNeteaseLogin = Route{Pattern: "POST /api/admin/credentials/netease/qr-logins/status", Auth: AuthAdmin, Status: 200}
RouteAdminStartQQLogin = Route{Pattern: "POST /api/admin/credentials/qq/qr-logins", Auth: AuthAdmin, Status: 201}
RouteAdminCheckQQLogin = Route{Pattern: "POST /api/admin/credentials/qq/qr-logins/status", Auth: AuthAdmin, Status: 200}
RouteAdminGetBilibiliCredential = Route{Pattern: "GET /api/admin/credentials/bilibili", Auth: AuthAdmin, Status: 200}
RouteAdminStartBilibiliLogin = Route{Pattern: "POST /api/admin/credentials/bilibili/qr-logins", Auth: AuthAdmin, Status: 201}
RouteAdminCheckBilibiliLogin = Route{Pattern: "POST /api/admin/credentials/bilibili/qr-logins/status", Auth: AuthAdmin, Status: 200}
RouteAdminGetYoutubeCredential = Route{Pattern: "GET /api/admin/credentials/youtube", Auth: AuthAdmin, Status: 200}
RouteAdminSetYoutubeCredential = Route{Pattern: "PUT /api/admin/credentials/youtube", Auth: AuthAdmin, Status: 204}
RouteAdminDeleteYoutubeCredential = Route{Pattern: "DELETE /api/admin/credentials/youtube", Auth: AuthAdmin, Status: 204}
RouteAdminListMedia = Route{Pattern: "GET /api/admin/media", Auth: AuthAdmin, Status: 200}
RouteAdminDeleteMedia = Route{Pattern: "DELETE /api/admin/media/{key}", Auth: AuthAdmin, Status: 204}
RouteAdminUpdateSite = Route{Pattern: "PATCH /api/admin/site", Auth: AuthAdmin, Status: 200}
)
var RoomRoutes = []RoomRoute{
{Route: Route{Pattern: "GET /api/room", Auth: AuthTicket, Status: 200}, Type: RequestRoomSync},
{Route: Route{Pattern: "POST /api/room/queue", Auth: AuthTicket, Status: 201, Location: true}, Type: RequestQueueAdd, body: true},
{Route: Route{Pattern: "DELETE /api/room/queue/{entryId}", Auth: AuthTicket, Status: 204}, Type: RequestQueueRemove, params: []string{"entryId"}},
{Route: Route{Pattern: "POST /api/room/queue/{entryId}/likes", Auth: AuthTicket, Status: 200}, Type: RequestQueueLike, params: []string{"entryId"}, body: true},
{Route: Route{Pattern: "POST /api/room/skip-votes", Auth: AuthTicket, Status: 200}, Type: RequestPlaybackVoteSkip, body: true},
{Route: Route{Pattern: "PATCH /api/room/settings", Auth: AuthTicket, Status: 204}, Type: RequestSettingsSetPlayMode, body: true},
{Route: Route{Pattern: "POST /api/room/messages", Auth: AuthTicket, Status: 204}, Type: RequestChatSend, body: true},
{Route: Route{Pattern: "GET /api/room/search/tracks", Auth: AuthTicket, Status: 200}, Type: RequestSearchTracks, query: map[string]queryKind{"keyword": queryString, "page": queryInteger, "pageSize": queryInteger, "source": queryString}},
{Route: Route{Pattern: "GET /api/room/search/playlists", Auth: AuthTicket, Status: 200}, Type: RequestSearchPlaylists, query: map[string]queryKind{"keyword": queryString, "page": queryInteger, "pageSize": queryInteger, "source": queryString}},
{Route: Route{Pattern: "GET /api/room/playlists/{source}/{playlistId}/tracks", Auth: AuthTicket, Status: 200}, Type: RequestPlaylistTracks, params: []string{"source", "playlistId"}},
{Route: Route{Pattern: "GET /api/room/recommendations", Auth: AuthTicket, Status: 200}, Type: RequestRecommendList},
}
var errorCodeHTTPStatus = map[ErrorCode]int{
ErrorCodeInvalidArgument: 400,
ErrorCodeUnauthorized: 401,
ErrorCodeForbidden: 403,
ErrorCodeNotFound: 404,
ErrorCodeConflict: 409,
ErrorCodeQueueFull: 409,
ErrorCodeNothingPlaying: 409,
ErrorCodeRateLimited: 429,
ErrorCodeProviderError: 502,
ErrorCodeUnavailable: 503,
ErrorCodeInternal: 500,
}
// IDL 没有为某个取值指定 HTTP 状态码时返回 0。
func (v ErrorCode) HTTPStatus() int {
return errorCodeHTTPStatus[v]
}
+80
View File
@@ -0,0 +1,80 @@
package protocol
import (
"encoding/json"
"strings"
"unicode/utf8"
)
type Message struct {
Kind string `json:"kind"`
Type string `json:"type"`
ID string `json:"id,omitempty"`
// 房间事件的 term 和 seq 都从 1 开始;响应和 error 事件不带编号,取 0 时不编码。
Term int64 `json:"term,omitempty"`
Seq int64 `json:"seq,omitempty"`
Data any `json:"data"`
Error *ErrorInfo `json:"error,omitempty"`
}
func Response(typ, id string, data any) Message {
return Message{Kind: "response", Type: typ, ID: id, Data: data}
}
func Failure(typ, id string, err ErrorInfo) Message {
return Message{Kind: "response", Type: typ, ID: id, Data: Empty{}, Error: &err}
}
func RoomEvent(typ string, term, seq int64, data any) Message {
return Message{Kind: "event", Type: typ, Term: term, Seq: seq, Data: data}
}
func ErrorEvent(err ErrorInfo) Message {
return Message{Kind: "event", Type: EventError, Data: err}
}
type Request struct {
Type string
ID string
Data any
}
// DecodeRequest 返回错误时,ID 为空表示这一帧无法对应到请求,应发送 error 事件;否则回复这个请求的失败响应。
func DecodeRequest(frame []byte) (Request, error) {
var env map[string]json.RawMessage
if json.Unmarshal(frame, &env) != nil || env == nil {
return Request{}, invalid("", "消息必须是 JSON 对象")
}
var kind, id, typ string
if json.Unmarshal(env["kind"], &kind) != nil || kind != "request" {
return Request{}, invalid("kind", `应为 "request"`)
}
if json.Unmarshal(env["id"], &id) != nil || utf8.RuneCountInString(id) < 1 || utf8.RuneCountInString(id) > 128 {
return Request{}, invalid("id", "应为 1–128 个字符的字符串")
}
if strings.ContainsRune(id, 0) {
return Request{}, invalid("id", "不能包含 U+0000")
}
if json.Unmarshal(env["type"], &typ) != nil || typ == "" {
return Request{}, invalid("type", "应为非空字符串")
}
req := Request{Type: typ, ID: id}
if err := rejectUnknown(env, "", "kind", "type", "id", "data"); err != nil {
return req, err
}
if err := requireFields(env, "", "data"); err != nil {
return req, err
}
data, known, err := decodeConnectionRequest(typ, env["data"], "data")
if !known {
data, known, err = decodeRoomRequest(typ, env["data"], "data")
}
if !known {
return req, invalid("type", "未知的请求类型")
}
if err != nil {
return req, err
}
req.Data = data
return req, nil
}