Compare commits

..
52 Commits
Author SHA1 Message Date
DiheChen d72b4203cb feat: browse stored media page by page, sort and search it, and delete several songs at once
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-11 00:23:50 +08:00
DiheChen 0f6ecaf6da feat: organize settings into sections that apply changes immediately 2026-11-11 00:22:33 +08:00
DiheChen 8cacfb68ff feat: organize the admin page into sections with room search, paging and source status cards 2026-11-11 00:21:31 +08:00
DiheChen c7a5c44b1c feat: add sectioned page layout, switches, segmented controls, paging and neutral avatars 2026-11-11 00:20:26 +08:00
DiheChen fb751ca57f feat: page, sort and search stored media and delete several at once in the admin API 2026-11-11 00:19:07 +08:00
DiheChen c39331aa75 feat: let the Android app open links to this site
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-11 00:17:49 +08:00
DiheChen 3bc1d6e82e feat: sign in with GitHub and manage the GitHub link in settings 2026-11-11 00:17:12 +08:00
DiheChen 2ae286dcb8 feat: pair a new device from settings with a code or QR code 2026-11-11 00:16:07 +08:00
DiheChen c1d5bb2de0 feat: offer GitHub login over HTTP when an OAuth app is configured 2026-11-11 00:15:38 +08:00
DiheChen 1157843f50 feat: expose device pairing over HTTP 2026-11-11 00:15:01 +08:00
DiheChen e95476185b feat: sign in with GitHub and link GitHub to accounts 2026-11-11 00:14:01 +08:00
DiheChen 7543fd4987 feat: pair new devices with one-time codes 2026-11-11 00:13:19 +08:00
DiheChen 6098ac392e feat: exchange GitHub OAuth codes for user IDs 2026-11-11 00:12:15 +08:00
DiheChen ad0d0900d8 feat: keep pairing codes and GitHub login states in Redis 2026-11-11 00:11:43 +08:00
DiheChen 240c8899a9 feat: store GitHub links on accounts that may have no password 2026-11-11 00:10:55 +08:00
DiheChen 7863c02170 feat: open shared playlist links to request, save and copy their songs 2026-11-11 00:10:12 +08:00
DiheChen 1f99bd939b feat: share my playlists from the playlist menu 2026-11-11 00:09:38 +08:00
DiheChen 8b941764b1 feat: expose playlist sharing over HTTP 2026-11-11 00:08:57 +08:00
DiheChen eed25578c7 feat: share playlists through unguessable links and copy shared playlists 2026-11-11 00:08:02 +08:00
DiheChen 9f23e97652 feat: store playlist share links and copy shared playlists 2026-11-11 00:07:07 +08:00
DiheChen 910e7339ad feat: show listening history from the server and sync the recording switch across devices 2026-11-11 00:06:40 +08:00
DiheChen 17e486209e feat: read, remove and clear listening history and switch recording over HTTP 2026-11-11 00:06:11 +08:00
DiheChen 112527db83 feat: write listening history as songs start and members join 2026-11-11 00:05:35 +08:00
DiheChen ce5ce141f8 feat: keep each member's listening history and move a guest's history into the account on login 2026-11-11 00:05:03 +08:00
DiheChen a12ab44ec8 feat: tell which members hear each song from the room domain 2026-11-11 00:04:01 +08:00
DiheChen 038cbb206b feat: import playlists from files, links and search results 2026-11-11 00:03:16 +08:00
DiheChen df8e0c5b8b feat: export playlists to files and import them in the web client 2026-11-11 00:02:22 +08:00
DiheChen 649916ab9d feat: expose playlist imports over HTTP 2026-11-11 00:01:47 +08:00
DiheChen 9256f64e24 feat: import playlists from files and from NetEase links 2026-11-11 00:01:00 +08:00
DiheChen 83f8dbf3a5 feat: look up prepared tracks in bulk and create a playlist with its songs at once 2026-11-11 00:00:18 +08:00
DiheChen 92a8e63240 feat: recognize NetEase and QQ Music playlist links and resolve their short links 2026-11-10 23:59:25 +08:00
DiheChen 1b292fa5e3 feat: select several playlist songs to remove, save or reorder them and drag songs into place 2026-11-09 21:33:06 +08:00
DiheChen a72fc40519 feat: browse my playlists as a sortable list or a glowing cover grid and open each one 2026-11-09 21:32:21 +08:00
DiheChen 0f01b1dfe9 feat: save songs to my playlists from the player, queue and history 2026-11-09 21:31:18 +08:00
DiheChen 5198fa5126 feat: track member playlists in the web client 2026-11-09 21:30:19 +08:00
DiheChen 96a3da903c feat: tell which of my playlists contain a song 2026-11-09 21:29:43 +08:00
DiheChen 2657dca9e1 feat: remove, copy and reorder several playlist songs at once 2026-11-09 21:28:12 +08:00
DiheChen fafde9041c feat: expose member playlists over HTTP 2026-11-09 21:27:41 +08:00
DiheChen 95a56267a4 feat: manage member playlists with server-side track metadata 2026-11-09 21:26:41 +08:00
DiheChen ffbf28c165 feat: store playlists with a fixed favorites list for every account 2026-11-09 21:25:18 +08:00
DiheChen f0f38ff511 feat: bind room tickets to member sessions and disconnect revoked sessions 2026-11-09 21:24:32 +08:00
DiheChen a80c48824f feat: register and sign in from the settings page 2026-11-09 21:23:23 +08:00
DiheChen a0956299be feat: switch registration and reset account passwords in the admin page 2026-11-09 21:22:40 +08:00
DiheChen 246baf5a0e feat: expose account registration, login and admin recovery over HTTP 2026-11-09 21:21:23 +08:00
DiheChen 62d99221fe feat: register, log in and manage member accounts 2026-11-09 21:20:12 +08:00
DiheChen fd1b3addb3 feat: store whether registration is open 2026-11-09 21:19:37 +08:00
DiheChen d8f9d4e2ca feat: store accounts and per-device sessions for members 2026-11-09 21:18:26 +08:00
DiheChen 98a1605a83 feat: reject emails that are not shaped like name@example.com when updating a profile 2026-11-09 21:17:42 +08:00
DiheChen 773ec302a7 feat: take the client address from proxies in private networks 2026-11-09 21:16:26 +08:00
DiheChen d17477ba59 feat: add a shared quota for account operations 2026-11-09 21:15:06 +08:00
DiheChen 97764f9769 docs: refine the open API documentation 2026-11-08 01:26:55 +08:00
DiheChen 47908cedcf ci: call CD only for tag builds
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-07 22:46:14 +08:00
340 changed files with 32935 additions and 1604 deletions

No files matched your search

+6 -9
View File
@@ -1,9 +1,10 @@
name: CD
on:
workflow_run:
workflows: [CI]
types: [completed]
workflow_call:
secrets:
K3S_KUBECONFIG:
required: true
concurrency:
group: cd-alisten
@@ -15,13 +16,9 @@ permissions:
jobs:
deploy:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
env:
TAG: ${{ github.event.workflow_run.head_branch }}
TAG: ${{ github.ref_name }}
steps:
- uses: azure/setup-helm@v5
with:
@@ -59,7 +56,7 @@ jobs:
shell: bash
env:
KUBECONFIG: ${{ runner.temp }}/k3s-kubeconfig
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
+10
View File
@@ -162,3 +162,13 @@ jobs:
repository="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
helm push "${RUNNER_TEMP}/helm-packages/alisten-${chart_version}.tgz" "oci://ghcr.io/${repository}/charts"
deploy:
needs: helm-release
if: github.ref_type == 'tag'
uses: ./.github/workflows/cd.yml
secrets:
K3S_KUBECONFIG: ${{ secrets.K3S_KUBECONFIG }}
permissions:
contents: read
packages: read
+19
View File
@@ -78,6 +78,25 @@ helm upgrade alisten \
--timeout 6m
```
## 开放 API
常驻房间支持生成 API Key,供第三方应用(如 IM 平台机器人)调用本项目的开放接口。调用时须通过 `Authorization: Bearer <API Key>` 请求头进行身份鉴权。API Key 仅在生成或重置时返回一次,请妥善保管。
完整的接口定义、请求参数与响应结构见 [OpenAPI 规范文件](protocol/openapi.gen.json)。
| 接口 | 说明 |
| --- | --- |
| `GET /api/room/search/tracks` | 搜索曲目 |
| `GET /api/room/search/playlists` | 搜索歌单 |
| `GET /api/room/playlists/{source}/{playlistId}/tracks` | 获取歌单内的曲目 |
| `GET /api/room/recommendations` | 获取推荐曲目 |
| `POST /api/room/queue` | 按曲目或关键词点歌 |
| `DELETE /api/room/queue/{entryId}` | 删除等待队列中的指定曲目 |
| `POST /api/room/queue/{entryId}/likes` | 为等待队列中的指定曲目点赞 |
| `POST /api/room/skip-votes` | 投票跳过当前播放的曲目 |
| `PATCH /api/room/settings` | 设置播放模式 |
| `POST /api/room/messages` | 发送房间聊天消息 |
## 使用与版权
本项目不提供音源内容及其使用授权。音源名称、音频、封面和歌词等内容的权利归各自权利人所有。
+5
View File
@@ -102,6 +102,8 @@ func (g *goDecoder) object(name string, s *schema) {
at := fmt.Sprintf("field(path, %q)", f)
decode := "decodeString"
switch {
case p.Type == "array":
decode = fmt.Sprintf("decodeArray(decode%s, %d)", refName(p.Items.Ref), *p.MaxItems)
case p.Ref != "":
decode = "decode" + refName(p.Ref)
case p.Type == "integer":
@@ -149,6 +151,9 @@ func (g *goDecoder) checks(value, at string, s *schema, patternName string) {
g.patterns = append(g.patterns, goPattern{name: name, expr: s.Pattern})
check("checkPattern", name)
}
if s.MinItems != nil {
check("checkMinItems", fmt.Sprint(*s.MinItems))
}
if s.Minimum != nil {
check("checkMinimum", fmt.Sprint(*s.Minimum))
}
+36 -3
View File
@@ -107,6 +107,8 @@ type schema struct {
Required []string `json:"required,omitempty"`
AdditionalProperties *bool `json:"additionalProperties,omitempty"`
Items *schema `json:"items,omitempty"`
MinItems *int `json:"minItems,omitempty"`
MaxItems *int `json:"maxItems,omitempty"`
AnyOf []*schema `json:"anyOf,omitempty"`
OneOf []*schema `json:"oneOf,omitempty"`
AllOf []*schema `json:"allOf,omitempty"`
@@ -439,21 +441,47 @@ func (d *idl) checkField(where string, s *schema, required, inbound bool) error
}
return d.checkValue(where+".anyOf[0]", s.AnyOf[0], inbound)
case s.Type == "array":
if inbound || !required {
return fmt.Errorf("%s:数组只能出现在响应和事件中,并且必须是必填字段", where)
if !required {
return fmt.Errorf("%s:数组必须是必填字段", where)
}
if err := allowOnly(where, s, "type", "description", "items"); err != nil {
allowed := []string{"type", "description", "items"}
if inbound {
allowed = append(allowed, "minItems", "maxItems")
}
if err := allowOnly(where, s, allowed...); err != nil {
return err
}
if s.Items == nil {
return fmt.Errorf("%s:数组缺少 items", where)
}
if inbound {
if err := d.checkInboundArray(where, s); err != nil {
return err
}
}
return d.checkValue(where+".items", s.Items, inbound)
default:
return d.checkValue(where, s, inbound)
}
}
// 请求中的数组元素只能引用对象或枚举类型,解码函数按类型名生成。
func (d *idl) checkInboundArray(where string, s *schema) error {
if s.Items.Ref == "" || d.Defs[refName(s.Items.Ref)] != nil && d.Defs[refName(s.Items.Ref)].scalar() {
return fmt.Errorf("%s:请求中的数组元素只能引用对象或枚举类型", where)
}
if s.MaxItems == nil {
return fmt.Errorf("%s:请求中的数组必须写 maxItems,解码时先按它拒绝过长的数组", where)
}
if s.MinItems != nil && *s.MinItems < 0 || *s.MaxItems < 0 {
return fmt.Errorf("%s:minItems 和 maxItems 不能是负数", where)
}
if s.MinItems != nil && *s.MinItems > *s.MaxItems {
return fmt.Errorf("%s:minItems 大于 maxItems", where)
}
return nil
}
func (d *idl) checkValue(where string, s *schema, inbound bool) error {
if s.Ref != "" {
if err := allowOnly(where, s, "$ref", "description"); err != nil {
@@ -962,6 +990,9 @@ func topLevelNames(f *ast.File) []string {
func (d *idl) markInbound(s *schema) error {
for _, name := range slices.Sorted(maps.Keys(s.Properties)) {
p := s.Properties[name]
if p.Type == "array" {
p = p.Items
}
if p.Ref == "" || d.inbound[refName(p.Ref)] || d.Defs[refName(p.Ref)].scalar() {
continue
}
@@ -1055,6 +1086,8 @@ func (s *schema) keywords() []string {
add(s.Required != nil, "required")
add(s.AdditionalProperties != nil, "additionalProperties")
add(s.Items != nil, "items")
add(s.MinItems != nil, "minItems")
add(s.MaxItems != nil, "maxItems")
add(s.AnyOf != nil, "anyOf")
add(s.OneOf != nil, "oneOf")
add(s.AllOf != nil, "allOf")
+22 -6
View File
@@ -143,17 +143,33 @@ func TestParseIDLRejects(t *testing.T) {
{"required 写了不存在的字段", func(d map[string]any) {
defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "string"}}, "message", "code")
}, "required 中的 code 不在 properties 里"},
{"请求中有数组", func(d map[string]any) {
{"请求中有标量数组", func(d map[string]any) {
d["x-requests"] = []any{roomRequest(object(map[string]any{
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, "ids"))}
}, "数组只能出现在响应和事件中"},
{"请求引用的类型中有数组", func(d map[string]any) {
}, "请求中的数组元素只能引用对象或枚举类型"},
{"请求引用的类型中有可选数组", func(d map[string]any) {
defs(d)["Batch"] = object(map[string]any{
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, "ids")
"ids": map[string]any{"type": "array", "items": ref("Empty")},
})
d["x-requests"] = []any{roomRequest(object(map[string]any{"batch": ref("Batch")}, "batch"))}
}, "$defs.Batch.properties.ids:数组只能出现在响应和事件中"},
}, "$defs.Batch.properties.ids:数组必须是必填字段"},
{"请求数组没有 maxItems", func(d map[string]any) {
d["x-requests"] = []any{roomRequest(object(map[string]any{
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 1},
}, "ids"))}
}, "请求中的数组必须写 maxItems"},
{"请求数组的 minItems 大于 maxItems", func(d map[string]any) {
d["x-requests"] = []any{roomRequest(object(map[string]any{
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 2, "maxItems": 1},
}, "ids"))}
}, "minItems 大于 maxItems"},
{"响应中的数组写了 minItems", func(d map[string]any) {
defs(d)["Batch"] = object(map[string]any{
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 1},
}, "ids")
d["x-events"] = []any{map[string]any{"type": "batch", "data": ref("Batch")}}
}, "不支持 minItems"},
{"生成的类型名重名", func(d map[string]any) {
defs(d)["RoomSyncRequest"] = object(map[string]any{})
d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
+22
View File
@@ -48,6 +48,10 @@ func DecodeCreateMemberRequest(body []byte) (CreateMemberRequest, error) {
return decodeCreateMemberRequest(body, "")
}
func DecodeRemoveTracksRequest(body []byte) (RemoveTracksRequest, error) {
return decodeRemoveTracksRequest(body, "")
}
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
return decodeCreateRoomRequest(body, "")
}
@@ -257,6 +261,24 @@ func decodeQueueRemoveRequest(raw json.RawMessage, path string) (QueueRemoveRequ
return v, nil
}
func decodeRemoveTracksRequest(raw json.RawMessage, path string) (RemoveTracksRequest, error) {
var v RemoveTracksRequest
m, err := decodeObject(raw, path, "tracks")
if err != nil {
return v, err
}
if err = requireFields(m, path, "tracks"); err != nil {
return v, err
}
if v.Tracks, err = decodeArray(decodeTrack, 3)(m["tracks"], field(path, "tracks")); err != nil {
return v, err
}
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
return v, err
}
return v, nil
}
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
var v RoomSyncRequest
_, err := decodeObject(raw, path)
+15
View File
@@ -212,6 +212,21 @@
"required": ["member", "token"]
}
},
{
"name": "removeTracks",
"method": "POST",
"path": "/api/tracks/removals",
"auth": "none",
"status": 204,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 3}
},
"required": ["tracks"]
}
},
{
"name": "getRoom",
"method": "GET",
+49 -1
View File
@@ -183,6 +183,23 @@
},
"type": "object"
},
"RemoveTracksRequest": {
"additionalProperties": false,
"properties": {
"tracks": {
"items": {
"$ref": "#/components/schemas/Track"
},
"maxItems": 3,
"minItems": 1,
"type": "array"
}
},
"required": [
"tracks"
],
"type": "object"
},
"Room": {
"additionalProperties": false,
"description": "房间",
@@ -318,7 +335,7 @@
},
"info": {
"title": "Sample",
"version": "362d144af81ba8bf"
"version": "5e495c1e23fa03a5"
},
"openapi": "3.1.0",
"paths": {
@@ -774,6 +791,37 @@
],
"summary": "座位上的成员,<b>座位号</b> 从 1 开始 & 不超过 8"
}
},
"/api/tracks/removals": {
"post": {
"operationId": "removeTracks",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RemoveTracksRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": []
}
}
}
}
+14 -1
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "362d144af81ba8bf"
const Version = "5e495c1e23fa03a5"
type AdminUpdateRoomRequest struct {
Homepage *string `json:"homepage,omitempty"`
@@ -88,6 +88,18 @@ type QueueRemoveRequest struct {
EntryID string `json:"entryId"`
}
type RemoveTracksRequest struct {
Tracks []Track `json:"tracks"`
}
func (v RemoveTracksRequest) MarshalJSON() ([]byte, error) {
type plain RemoveTracksRequest
if v.Tracks == nil {
v.Tracks = []Track{}
}
return json.Marshal(plain(v))
}
type Room struct {
Gain float64 `json:"gain"`
ID string `json:"id"`
@@ -154,6 +166,7 @@ const (
var (
RouteCreateMember = Route{Pattern: "POST /api/members", Auth: AuthNone, Status: 201}
RouteRemoveTracks = Route{Pattern: "POST /api/tracks/removals", Auth: AuthNone, Status: 204}
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
RouteGetSeat = Route{Pattern: "GET /api/rooms/{ref}/seats/{seat}", Auth: AuthMember, Status: 200}
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
import type { ErrorInfo, Member, Room } from '../realtime/protocol.gen'
import type { ErrorInfo, Member, Room, Track } from '../realtime/protocol.gen'
export interface AdminUpdateRoomRequest {
homepage?: string
@@ -25,6 +25,10 @@ export interface CreateRoomResponse {
id: string
}
export interface RemoveTracksRequest {
tracks: Track[]
}
export interface ErrorResponse {
error: ErrorInfo
}
@@ -43,6 +47,7 @@ export type HttpSend = <T>(request: HttpRequest) => Promise<T>
export function httpClient(send: HttpSend) {
return {
createMember: () => send<CreateMemberResponse>({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
removeTracks: (body: RemoveTracksRequest) => send<void>({ method: 'POST', path: '/api/tracks/removals', auth: 'none', body }),
getRoom: (ref: string) => send<Room>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
getSeat: (ref: string, seat: string) => send<Member>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}/seats/${encodeURIComponent(seat)}`, auth: 'member' }),
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '362d144af81ba8bf'
export const PROTOCOL_VERSION = '5e495c1e23fa03a5'
export type Empty = Record<string, never>
+39
View File
@@ -0,0 +1,39 @@
-- +goose Up
-- 一个会话对应一个成员 token,同一个成员可以在多台设备上各有一个会话。
CREATE TABLE member_sessions (
token_hash bytea PRIMARY KEY,
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON member_sessions (member_id);
-- bot 成员只通过 API Key 使用 HTTP 接口,不需要会话。
INSERT INTO member_sessions (token_hash, member_id, created_at)
SELECT m.token_hash, m.id, m.created_at FROM members m
WHERE NOT EXISTS (SELECT FROM rooms r WHERE r.bot_member_id = m.id);
ALTER TABLE members DROP COLUMN token_hash;
-- 设置了用户名的成员是已注册的账号,否则是游客。用户名只能由小写字母、数字、下划线和连字符组成,所以按原文唯一即可。
-- 邮箱属于账号,在所有设备上一致;游客没有账号,不替匿名身份保存个人信息,邮箱只存在游客自己的浏览器里。
ALTER TABLE members
ADD COLUMN username text UNIQUE,
ADD COLUMN password_hash text,
ADD CONSTRAINT members_password_check CHECK (password_hash IS NULL OR username IS NOT NULL),
ADD COLUMN email text,
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL);
-- site 表没有行或者 brand_name 为 NULL 时,品牌名是默认的 Alisten;没有行时开放注册。
ALTER TABLE site
ALTER COLUMN brand_name DROP NOT NULL,
ADD COLUMN registration_open boolean NOT NULL DEFAULT true;
-- 票据属于签发它的会话,会话删除时票据随之失效。bot 的 API Key 票据没有会话。
ALTER TABLE room_tickets ADD COLUMN session_token_hash bytea REFERENCES member_sessions ON DELETE CASCADE;
CREATE INDEX ON room_tickets (session_token_hash);
UPDATE room_tickets t SET session_token_hash = s.token_hash
FROM member_sessions s
WHERE s.member_id = t.member_id;
+35
View File
@@ -0,0 +1,35 @@
-- +goose Up
CREATE TABLE playlists (
id uuid PRIMARY KEY DEFAULT uuidv7(),
owner_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
-- 每个账号恰好一个“我喜欢”:注册时创建,不能改名或删除。
kind text NOT NULL CHECK (kind IN ('favorites', 'custom')),
name text NOT NULL,
-- 新加入的曲目取 item_seq + 1 作为排序值,排在最前。
item_seq bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
-- 歌单改名或曲目增减的时间。
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON playlists (owner_id);
CREATE UNIQUE INDEX ON playlists (owner_id) WHERE kind = 'favorites';
-- 展示用的元数据存一份快照:歌单里的歌不一定准备过,tracks 表里可能没有。
CREATE TABLE playlist_items (
playlist_id uuid NOT NULL REFERENCES playlists ON DELETE CASCADE,
source text NOT NULL,
track_id text NOT NULL,
title text NOT NULL,
artist text,
album text,
cover_url text,
cover_key text,
duration_ms bigint NOT NULL,
page_url text NOT NULL,
position bigint NOT NULL,
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (playlist_id, source, track_id)
);
CREATE INDEX ON playlist_items (playlist_id, position DESC);
+21
View File
@@ -0,0 +1,21 @@
-- +goose Up
-- 关闭之后不再记录新的播放历史,已有的保留。
ALTER TABLE members ADD COLUMN record_listens boolean NOT NULL DEFAULT true;
-- 成员在房间里听到过的歌,每首只留最近一次。元数据存一份快照,不关联 tracks:管理员删除已存储的媒体时会连带删掉 tracks 中对应的行。
CREATE TABLE listens (
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
source text NOT NULL,
track_id text NOT NULL,
title text NOT NULL,
artist text,
album text,
cover_url text,
cover_key text,
duration_ms bigint NOT NULL,
page_url text NOT NULL,
heard_at timestamptz NOT NULL,
PRIMARY KEY (member_id, source, track_id)
);
CREATE INDEX ON listens (member_id, heard_at DESC);
+3
View File
@@ -0,0 +1,3 @@
-- +goose Up
-- 分享链接中的随机串,NULL 表示没有分享。明文保存:所有者随时可以再复制一次链接。
ALTER TABLE playlists ADD COLUMN share_id text UNIQUE;
+6
View File
@@ -0,0 +1,6 @@
-- +goose Up
-- 绑定了 GitHub 的成员也是已注册的账号,可以没有用户名和密码;账号的邮箱因此也可以属于只绑定了 GitHub 的成员。
ALTER TABLE members
ADD COLUMN github_id bigint UNIQUE,
DROP CONSTRAINT members_email_check,
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL OR github_id IS NOT NULL);
+57
View File
@@ -0,0 +1,57 @@
-- name: LockListeners :many
-- 按 ID 的顺序锁住开启了记录的成员:同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删;
-- 加锁的顺序固定,同时写几个成员的事务之间不会死锁。
SELECT id FROM members WHERE id = ANY(@ids::uuid[]) AND record_listens ORDER BY id FOR NO KEY UPDATE;
-- name: UpsertListens :exec
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT m, @source::text, @track_id::text, @title::text, sqlc.narg(artist)::text, sqlc.narg(album)::text, sqlc.narg(cover_url)::text,
sqlc.narg(cover_key)::text, @duration_ms::bigint, @page_url::text, @heard_at::timestamptz
FROM unnest(@member_ids::uuid[]) AS m
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: MoveListens :exec
-- 并入的规则与 UpsertListens 相同,并入的那份算新写入的;to_id 关闭了记录时只删不并。
WITH moved AS (
DELETE FROM listens WHERE member_id = sqlc.arg(from_id)::uuid
RETURNING source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
)
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT sqlc.arg(to_id)::uuid, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM moved
WHERE EXISTS (SELECT FROM members WHERE id = sqlc.arg(to_id)::uuid AND record_listens)
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: TrimListens :exec
-- 排序与 MemberListens 相同:留下的正是读取时返回的那些。
DELETE FROM listens l
USING (
SELECT member_id, source, track_id,
row_number() OVER (PARTITION BY member_id ORDER BY heard_at DESC, source, track_id) AS n
FROM listens WHERE member_id = ANY(@member_ids::uuid[])
) ranked
WHERE l.member_id = ranked.member_id AND l.source = ranked.source AND l.track_id = ranked.track_id AND ranked.n > @keep::bigint;
-- name: MemberListens :many
SELECT source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM listens WHERE member_id = @member_id
ORDER BY heard_at DESC, source, track_id
LIMIT @max;
-- name: DeleteListen :exec
DELETE FROM listens WHERE member_id = @member_id AND source = @source AND track_id = @track_id;
-- name: ClearListens :exec
DELETE FROM listens WHERE member_id = @member_id;
-- name: ListenRecording :one
SELECT record_listens FROM members WHERE id = @id;
-- name: SetListenRecording :execrows
UPDATE members SET record_listens = @record_listens WHERE id = @id;
+13 -4
View File
@@ -1,10 +1,19 @@
-- name: ListStoredMedia :many
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL
ORDER BY prepared_at DESC, track_id;
-- 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\'
ORDER BY CASE WHEN @sort::text = 'stored' THEN prepared_at END DESC,
CASE WHEN @sort::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
CASE WHEN @sort::text = 'size' THEN media_size END DESC,
track_id COLLATE "C"
LIMIT @page_size::bigint OFFSET @skip::bigint;
-- name: CountStoredMedia :one
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\';
-- name: StoredMedia :one
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key = @media_key::text;
-- name: DeleteStoredMedia :exec
+66 -4
View File
@@ -1,14 +1,76 @@
-- name: CreateMember :one
INSERT INTO members (token_hash, name) VALUES (@token_hash, @name)
INSERT INTO members (name) VALUES (@name)
RETURNING *;
-- name: CreateSession :exec
INSERT INTO member_sessions (token_hash, member_id) VALUES (@token_hash, @member_id);
-- name: MemberByToken :one
SELECT * FROM members WHERE token_hash = @token_hash;
SELECT * FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = @token_hash);
-- name: UpdateMember :one
-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。
-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
UPDATE members
SET name = coalesce(sqlc.narg(name), name),
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END,
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
WHERE id = @id
RETURNING *;
-- name: MemberAccountProfile :one
SELECT username, email, github_id FROM members WHERE id = @id;
-- name: RegisterMember :execrows
-- 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL AND github_id IS NULL;
-- name: AddMemberCredentials :execrows
-- 只给只绑定了 GitHub 的账号设置,它的“我喜欢”已经在绑定时创建了。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL AND github_id IS NOT NULL;
-- name: AccountByUsername :one
SELECT * FROM members WHERE username = @username;
-- name: AccountByID :one
SELECT * FROM members WHERE id = @id AND (username IS NOT NULL OR github_id IS NOT NULL);
-- name: AccountByGitHub :one
SELECT * FROM members WHERE github_id = @github_id;
-- name: BindGitHub :one
-- 只给还没有绑定 GitHub 的成员绑定,已经绑定过的成员不受影响,没有返回的行。was_guest 表示成员原来是游客:这次绑定就是注册。
UPDATE members SET github_id = @github_id
WHERE id = @id AND github_id IS NULL
RETURNING (username IS NULL)::boolean AS was_guest;
-- name: UnbindGitHub :execrows
-- 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
UPDATE members SET github_id = NULL WHERE id = @id AND password_hash IS NOT NULL;
-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = @password_hash WHERE id = @id AND username IS NOT NULL;
-- name: LockAccountPassword :one
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id AND username IS NOT NULL FOR NO KEY UPDATE;
-- name: MemberHasSession :one
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = @token_hash AND member_id = @member_id);
-- name: LockMemberPassword :one
-- 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的几次登录依次并入游客的历史,否则两边会按相反的顺序抢同一批历史行而死锁。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR NO KEY UPDATE;
-- name: DeleteSession :execrows
DELETE FROM member_sessions WHERE token_hash = @token_hash;
-- name: TakeSession :one
DELETE FROM member_sessions WHERE token_hash = @token_hash RETURNING member_id;
-- name: DeleteMemberSessions :many
-- keep 为 NULL 时删除这个成员的全部会话。
DELETE FROM member_sessions WHERE member_id = @member_id AND token_hash IS DISTINCT FROM sqlc.narg(keep)::bytea
RETURNING token_hash;
+140
View File
@@ -0,0 +1,140 @@
-- name: CreateFavorites :exec
INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'favorites', '我喜欢');
-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id
GROUP BY p.id
ORDER BY p.kind = 'favorites' DESC, p.created_at, p.id;
-- name: MemberPlaylistCovers :many
SELECT DISTINCT ON (i.playlist_id) i.playlist_id, i.cover_url, i.cover_key
FROM playlist_items i
JOIN playlists p ON p.id = i.playlist_id
WHERE p.owner_id = @owner_id AND (i.cover_url IS NOT NULL OR i.cover_key IS NOT NULL)
ORDER BY i.playlist_id, i.position DESC;
-- name: LockFavorites :exec
-- 新建歌单前锁住这个账号的“我喜欢”:同一个账号并发新建时依次计数,不会越过上限。
SELECT id FROM playlists WHERE owner_id = @owner_id AND kind = 'favorites' FOR UPDATE;
-- name: CountPlaylists :one
SELECT count(*) FROM playlists WHERE owner_id = @owner_id;
-- name: CreatePlaylist :one
INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'custom', @name)
RETURNING id, created_at, updated_at;
-- name: PlaylistKind :one
SELECT kind FROM playlists WHERE id = @id AND owner_id = @owner_id;
-- name: RenamePlaylist :execrows
UPDATE playlists SET name = @name, updated_at = now() WHERE id = @id AND owner_id = @owner_id AND kind = 'custom';
-- name: DeletePlaylist :execrows
DELETE FROM playlists WHERE id = @id AND owner_id = @owner_id AND kind = 'custom';
-- name: LockPlaylist :one
-- 改动曲目前锁住歌单:并发加入时依次检查上限、分配排序值。删除歌单先锁歌单行再级联删除曲目,其他操作也要先锁歌单再动曲目,否则会互相死锁。
SELECT item_seq FROM playlists WHERE id = @id AND owner_id = @owner_id FOR UPDATE;
-- name: PlaylistHasItem :one
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = @playlist_id AND source = @source AND track_id = @track_id);
-- name: CountPlaylistItems :one
SELECT count(*) FROM playlist_items WHERE playlist_id = @playlist_id;
-- name: InsertPlaylistItem :exec
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
-- name: InsertPlaylistItems :copyfrom
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
-- name: SetPlaylistSeq :exec
UPDATE playlists SET item_seq = @item_seq, updated_at = now() WHERE id = @id;
-- name: PlaylistItems :many
SELECT * FROM playlist_items WHERE playlist_id = @playlist_id ORDER BY position DESC;
-- name: DeletePlaylistItems :exec
WITH deleted AS (
DELETE FROM playlist_items i
USING unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS t(track_id, ord) USING (ord)
WHERE i.playlist_id = @playlist_id AND i.source = s.source AND i.track_id = t.track_id
RETURNING i.playlist_id
)
UPDATE playlists SET updated_at = now() WHERE id IN (SELECT playlist_id FROM deleted);
-- name: CopyPlaylistItems :execrows
-- 按请求的先后复制:第一首的排序值最大。请求里重复的歌只取第一次出现的位置。
WITH wanted AS (
SELECT s.source, k.track_id, min(s.ord) AS ord
FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
GROUP BY s.source, k.track_id
), copied AS (
SELECT i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY w.ord DESC) AS rank
FROM wanted w
JOIN playlist_items i ON i.playlist_id = @from_id AND i.source = w.source AND i.track_id = w.track_id
WHERE NOT EXISTS (
SELECT FROM playlist_items d WHERE d.playlist_id = @to_id AND d.source = w.source AND d.track_id = w.track_id
)
)
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT sqlc.arg(to_id), source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, sqlc.arg(item_seq)::bigint + rank
FROM copied;
-- name: PlaylistTracks :many
SELECT source, track_id FROM playlist_items WHERE playlist_id = @playlist_id ORDER BY position DESC;
-- name: SetPlaylistPositions :exec
UPDATE playlist_items i SET position = t.position
FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN unnest(@positions::bigint[]) WITH ORDINALITY AS t(position, ord) USING (ord)
WHERE i.playlist_id = @playlist_id AND i.source = s.source AND i.track_id = k.track_id;
-- name: TouchPlaylist :exec
UPDATE playlists SET updated_at = now() WHERE id = @id;
-- name: TrackPlaylists :many
SELECT p.id FROM playlists p
JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id AND i.source = @source AND i.track_id = @track_id
ORDER BY p.id;
-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = @share_id WHERE id = @id AND owner_id = @owner_id;
-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = sqlc.arg(share_id)::text;
-- name: CopySharedPlaylistItems :one
-- 确认分享还在和复制曲目在同一条语句里,看到的是同一个快照:要么完整复制,要么读不到分享。不锁来源歌单:两个人同时复制
-- 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
WITH shared AS (
SELECT id FROM playlists WHERE share_id = sqlc.arg(share_id)::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT sqlc.arg(to_id), i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied;
-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = @playlist_id AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1;
+6 -2
View File
@@ -1,6 +1,10 @@
-- name: BrandName :one
SELECT brand_name FROM site;
-- name: SiteSettings :one
SELECT brand_name, registration_open FROM site;
-- name: SetBrandName :exec
INSERT INTO site (brand_name) VALUES (@brand_name)
ON CONFLICT (id) DO UPDATE SET brand_name = excluded.brand_name;
-- name: SetRegistrationOpen :exec
INSERT INTO site (registration_open) VALUES (@registration_open)
ON CONFLICT (id) DO UPDATE SET registration_open = excluded.registration_open;
+7 -2
View File
@@ -3,11 +3,16 @@
SELECT coalesce(password_hash, '')::text AS password_hash FROM rooms WHERE id = @id FOR SHARE;
-- name: CreateTicket :exec
INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES (@token_hash, @room_id, @member_id);
-- bot 的 API Key 没有会话,session_token_hash 为 NULL。
INSERT INTO room_tickets (token_hash, room_id, member_id, session_token_hash)
VALUES (@token_hash, @room_id, @member_id, sqlc.narg(session_token_hash));
-- name: TicketHolder :one
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot, t.session_token_hash
FROM room_tickets t
JOIN members m ON m.id = t.member_id
JOIN rooms r ON r.id = t.room_id
WHERE t.token_hash = @token_hash;
-- name: ExistingTickets :many
SELECT token_hash FROM room_tickets WHERE token_hash = ANY(@token_hashes::bytea[]);
+6
View File
@@ -12,3 +12,9 @@ ON CONFLICT (source, track_id) DO UPDATE SET
lyric = excluded.lyric, lyric_words = excluded.lyric_words, lyric_trans = excluded.lyric_trans,
media_key = excluded.media_key, media_size = excluded.media_size,
prepared_at = now();
-- name: TrackMetas :many
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id;
+110
View File
@@ -0,0 +1,110 @@
package api
import (
"context"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) register(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRegisterRequest(c.body)
if err != nil {
return nil, err
}
account, err := s.Accounts.Register(ctx, c.member.ID, bearer(c.r), c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
return protocol.RegisterResponse{Account: accountDTO(account)}, nil
}
func (s *server) login(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeLoginRequest(c.body)
if err != nil {
return nil, err
}
session, account, device, err := s.Accounts.Login(ctx, c.member, bearer(c.r), c.clientIP, req.Username, req.Password, deref(req.Device))
if err != nil {
return nil, err
}
return protocol.LoginResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account), Device: device}, nil
}
func (s *server) logout(ctx context.Context, c call) (any, error) {
return nil, s.Accounts.Logout(ctx, bearer(c.r))
}
func (s *server) changePassword(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeChangePasswordRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Accounts.ChangePassword(ctx, c.member.ID, bearer(c.r), req.CurrentPassword, req.NewPassword)
}
func (s *server) adminGetAccount(ctx context.Context, c call) (any, error) {
username := c.r.PathValue("username")
if !usernamePattern.MatchString(username) {
return nil, identity.ErrAccountNotFound
}
account, err := s.Accounts.Find(ctx, username)
if err != nil {
return nil, err
}
return protocol.AdminAccount{Member: memberDTO(account.Member), Username: account.Username}, nil
}
func (s *server) adminResetAccountPassword(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeAdminResetAccountPasswordRequest(c.body); err != nil {
return nil, err
}
username := c.r.PathValue("username")
if !usernamePattern.MatchString(username) {
return nil, identity.ErrAccountNotFound
}
password, err := s.Accounts.ResetPassword(ctx, username)
if err != nil {
return nil, err
}
return protocol.AdminResetAccountPasswordResponse{Password: password}, nil
}
func (s *server) createPairingCode(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeCreatePairingCodeRequest(c.body); err != nil {
return nil, err
}
code, err := s.Accounts.CreatePairingCode(ctx, c.member.ID, bearer(c.r))
if err != nil {
return nil, err
}
// 配对码放在片段里,不会出现在请求和 Referer 中。
image, err := qrDataURI(s.PublicURL + "/pair#" + code.Code)
if err != nil {
return nil, err
}
return protocol.CreatePairingCodeResponse{Code: code.Code, QrImage: image, ExpiresAtMs: code.ExpiresAt.UnixMilli()}, nil
}
func (s *server) redeemPairingCode(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRedeemPairingCodeRequest(c.body)
if err != nil {
return nil, err
}
session, account, err := s.Accounts.RedeemPairingCode(ctx, c.member, bearer(c.r), c.clientIP, req.Code)
if err != nil {
return nil, err
}
return protocol.RedeemPairingCodeResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account)}, nil
}
func accountDTO(a identity.Account) protocol.Account {
dto := protocol.Account{Github: a.GitHub}
if a.Username != "" {
dto.Username = &a.Username
}
if a.Email != "" {
dto.Email = &a.Email
}
return dto
}
+109 -19
View File
@@ -19,34 +19,47 @@ import (
"github.com/dihemoe/alisten/internal/app/admin"
"github.com/dihemoe/alisten/internal/app/catalog"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/listen"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/internal/app/site"
"github.com/dihemoe/alisten/internal/domain/member"
playlistorder "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/room"
"github.com/dihemoe/alisten/internal/lib/logctx"
"github.com/dihemoe/alisten/protocol"
)
const (
maxBodyBytes = 64 << 10
requestDeadline = 3 * time.Minute
maxBodyBytes = 64 << 10
// 歌单的批量接口一次最多 1000 首,链接曲目的 ID 最长 2048 字节,每首再留 256 字节给来源、字段名和 JSON 的标点。
maxBatchBodyBytes = 1000 * (2048 + 256)
// 导入文件一次最多 1000 首,每首除了曲目还带标题、歌手、专辑和两个地址。nginx 中 /api/me/playlists/imports 的上限要与它保持一致。
maxImportBodyBytes = 1 << 20
requestDeadline = 3 * time.Minute
)
type Services struct {
Identity *identity.Service
Lobby *lobby.Service
Admin *admin.Service
Gateway *realtime.Gateway
Catalog *catalog.Service
Media *media.Library
Playback *media.Playback
Site *site.Service
Identity *identity.Service
Accounts *identity.Accounts
Playlists *playlist.Service
Lobby *lobby.Service
Admin *admin.Service
Gateway *realtime.Gateway
Catalog *catalog.Service
Media *media.Library
Playback *media.Playback
Site *site.Service
Listens *listen.Service
GitHub *identity.GitHub
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
StorageURL string
// 网页的 origin,结尾没有 /。
PublicURL string
}
type server struct {
@@ -63,8 +76,12 @@ type call struct {
member member.Member
roomID string
ticket string
// 票据签发给的成员会话的 token 哈希,API Key 没有会话。
memberSession []byte
// 票据是常驻房间的 API Key。
bot bool
// 发出请求的客户端地址,按来源限流时使用。
clientIP string
}
type handlerFunc func(ctx context.Context, c call) (any, error)
@@ -96,6 +113,23 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteCreateMember, srv.createMember)
srv.handle(mux, protocol.RouteGetMe, srv.getMe)
srv.handle(mux, protocol.RouteUpdateMe, srv.updateMe)
srv.handle(mux, protocol.RouteRegister, srv.register)
srv.handle(mux, protocol.RouteChangePassword, srv.changePassword)
srv.handle(mux, protocol.RouteLogin, srv.login)
srv.handle(mux, protocol.RouteLogout, srv.logout)
srv.handle(mux, protocol.RouteListMyPlaylists, srv.listMyPlaylists)
srv.handle(mux, protocol.RouteCreateMyPlaylist, srv.createMyPlaylist)
srv.handle(mux, protocol.RouteRenameMyPlaylist, srv.renameMyPlaylist)
srv.handle(mux, protocol.RouteDeleteMyPlaylist, srv.deleteMyPlaylist)
srv.handle(mux, protocol.RouteListMyPlaylistItems, srv.listMyPlaylistItems)
srv.handle(mux, protocol.RouteAddMyPlaylistItem, srv.addMyPlaylistItem)
srv.handleLimit(mux, protocol.RouteRemoveMyPlaylistItems, maxBatchBodyBytes, srv.removeMyPlaylistItems)
srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists)
srv.handleLimit(mux, protocol.RouteCopyMyPlaylistItems, maxBatchBodyBytes, srv.copyMyPlaylistItems)
srv.handleLimit(mux, protocol.RouteMoveMyPlaylistItems, maxBatchBodyBytes, srv.moveMyPlaylistItems)
srv.handleLimit(mux, protocol.RouteImportMyPlaylist, maxImportBodyBytes, srv.importMyPlaylist)
srv.handle(mux, protocol.RouteResolveMyPlaylistLink, srv.resolveMyPlaylistLink)
srv.handle(mux, protocol.RouteImportMyPlatformPlaylist, srv.importMyPlatformPlaylist)
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -122,9 +156,31 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminGetYoutubeCredential, srv.adminGetYoutubeCredential)
srv.handle(mux, protocol.RouteAdminSetYoutubeCredential, srv.adminSetYoutubeCredential)
srv.handle(mux, protocol.RouteAdminDeleteYoutubeCredential, srv.adminDeleteYoutubeCredential)
srv.handle(mux, protocol.RouteAdminGetGithubCredential, srv.adminGetGithubCredential)
srv.handle(mux, protocol.RouteAdminSetGithubCredential, srv.adminSetGithubCredential)
srv.handle(mux, protocol.RouteAdminDeleteGithubCredential, srv.adminDeleteGithubCredential)
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminRemoveMedia, srv.adminRemoveMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
srv.handle(mux, protocol.RouteAdminOpenRegistration, srv.adminOpenRegistration)
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
srv.handle(mux, protocol.RouteAdminGetAccount, srv.adminGetAccount)
srv.handle(mux, protocol.RouteAdminResetAccountPassword, srv.adminResetAccountPassword)
srv.handle(mux, protocol.RouteListMyListens, srv.listMyListens)
srv.handle(mux, protocol.RouteRemoveMyListen, srv.removeMyListen)
srv.handle(mux, protocol.RouteClearMyListens, srv.clearMyListens)
srv.handle(mux, protocol.RouteEnableListenRecording, srv.enableListenRecording)
srv.handle(mux, protocol.RouteDisableListenRecording, srv.disableListenRecording)
srv.handle(mux, protocol.RouteShareMyPlaylist, srv.shareMyPlaylist)
srv.handle(mux, protocol.RouteUnshareMyPlaylist, srv.unshareMyPlaylist)
srv.handle(mux, protocol.RouteGetSharedPlaylist, srv.getSharedPlaylist)
srv.handle(mux, protocol.RouteCopySharedPlaylist, srv.copySharedPlaylist)
srv.handle(mux, protocol.RouteCreatePairingCode, srv.createPairingCode)
srv.handle(mux, protocol.RouteRedeemPairingCode, srv.redeemPairingCode)
srv.handle(mux, protocol.RouteStartGithubLogin, srv.startGithubLogin)
srv.handle(mux, protocol.RouteFinishGithubLogin, srv.finishGithubLogin)
srv.handle(mux, protocol.RouteUnbindGithub, srv.unbindGithub)
mux.HandleFunc("GET "+GitHubCallbackPath, srv.githubCallback)
mux.HandleFunc("GET /ws", srv.websocket)
mux.HandleFunc("GET /api/room/events", srv.roomEvents)
mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia)
@@ -148,10 +204,14 @@ func SameOrigin(allowedOrigins []string, next http.Handler) http.Handler {
}
func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc) {
s.handleLimit(mux, route, maxBodyBytes, fn)
}
func (s *server) handleLimit(mux *http.ServeMux, route protocol.Route, maxBody int64, fn handlerFunc) {
mux.HandleFunc(route.Pattern, func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
defer cancel()
ctx, resp, err := s.serve(ctx, w, r, route.Auth, func(ctx context.Context, c call) (any, error) {
ctx, resp, err := s.serve(ctx, w, r, route.Auth, maxBody, func(ctx context.Context, c call) (any, error) {
resp, err := fn(ctx, c)
if err != nil || !route.Location {
return resp, err
@@ -174,13 +234,13 @@ func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc
// 先鉴权再读请求体:后台没有启用时,无论请求是否合法都返回 FORBIDDEN。返回的 ctx 带有凭证所属的成员和房间,
// 出错时的日志也要带上它们。
func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, fn handlerFunc) (logCtx context.Context, _ any, err error) {
func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, maxBody int64, fn handlerFunc) (logCtx context.Context, _ any, err error) {
defer func() {
if value := recover(); value != nil {
logCtx, err = ctx, s.panicked(ctx, r, value)
}
}()
c := call{r: r}
c := call{r: r, clientIP: clientIP(r)}
switch auth {
case protocol.AuthNone:
case protocol.AuthMember:
@@ -196,7 +256,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque
if err != nil {
return ctx, nil, err
}
c.roomID, c.member, c.bot = holder.RoomID, holder.Member, holder.Bot
c.roomID, c.member, c.memberSession, c.bot = holder.RoomID, holder.Member, holder.SessionTokenHash, holder.Bot
ctx = logctx.WithMember(logctx.WithRoom(ctx, holder.RoomID), holder.Member.ID)
case protocol.AuthAdmin:
if err := s.Admin.Authorize(bearer(r)); err != nil {
@@ -207,7 +267,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque
return ctx, nil, fmt.Errorf("不支持的凭证类型 %d", auth)
}
if r.Method != http.MethodGet && r.Method != http.MethodDelete {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
if err != nil {
return ctx, nil, bodyError(r, err)
}
@@ -266,9 +326,7 @@ var knownErrors = []struct {
{errUnreadableBody, protocol.ErrorCodeInvalidArgument},
{port.ErrRoomNotFound, protocol.ErrorCodeNotFound},
{port.ErrSlugTaken, protocol.ErrorCodeConflict},
{media.ErrNotFound, protocol.ErrorCodeNotFound},
{media.ErrNotInRoom, protocol.ErrorCodeNotFound},
{media.ErrInUse, protocol.ErrorCodeConflict},
{identity.ErrInvalidMemberToken, protocol.ErrorCodeUnauthorized},
{identity.ErrWrongPassword, protocol.ErrorCodeUnauthorized},
{admin.ErrInvalidToken, protocol.ErrorCodeUnauthorized},
@@ -276,6 +334,29 @@ var knownErrors = []struct {
{admin.ErrUnrecognizedCookies, protocol.ErrorCodeInvalidArgument},
{identity.ErrInvalidTicket, protocol.ErrorCodeUnauthorized},
{identity.ErrBotSession, protocol.ErrorCodeUnauthorized},
{identity.ErrInvalidEmail, protocol.ErrorCodeInvalidArgument},
{identity.ErrInvalidCredentials, protocol.ErrorCodeInvalidCredentials},
{identity.ErrWrongCurrentPassword, protocol.ErrorCodeInvalidCredentials},
{identity.ErrAlreadyLoggedIn, protocol.ErrorCodeConflict},
{identity.ErrRegistrationClosed, protocol.ErrorCodeForbidden},
{identity.ErrNotRegistered, protocol.ErrorCodeForbidden},
{identity.ErrTooManyAttempts, protocol.ErrorCodeRateLimited},
{identity.ErrAccountNotFound, protocol.ErrorCodeNotFound},
{port.ErrUsernameTaken, protocol.ErrorCodeConflict},
{port.ErrAlreadyRegistered, protocol.ErrorCodeConflict},
{port.ErrPlaylistNotFound, protocol.ErrorCodeNotFound},
{port.ErrFavoritesFixed, protocol.ErrorCodeConflict},
{playlist.ErrNotRegistered, protocol.ErrorCodeForbidden},
{playlist.ErrInvalidName, protocol.ErrorCodeInvalidArgument},
{playlist.ErrTooManyPlaylists, protocol.ErrorCodeConflict},
{playlist.ErrPlaylistFull, protocol.ErrorCodeConflict},
{playlist.ErrTrackNotReady, protocol.ErrorCodeConflict},
{playlist.ErrTooFast, protocol.ErrorCodeRateLimited},
{playlist.ErrInvalidPlaylistLink, protocol.ErrorCodeInvalidArgument},
{playlist.ErrUnsupportedPlaylist, protocol.ErrorCodeInvalidArgument},
{playlist.ErrShortLinkFailed, protocol.ErrorCodeProviderError},
{playlistorder.ErrBeforeMoved, protocol.ErrorCodeInvalidArgument},
{playlistorder.ErrBeforeMissing, protocol.ErrorCodeConflict},
{realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument},
{site.ErrInvalidBrandName, protocol.ErrorCodeInvalidArgument},
{realtime.ErrUnavailable, protocol.ErrorCodeUnavailable},
@@ -285,6 +366,15 @@ var knownErrors = []struct {
{catalog.ErrRateLimited, protocol.ErrorCodeRateLimited},
{errClientOutdated, protocol.ErrorCodeClientOutdated},
{errPanicked, protocol.ErrorCodeInternal},
{port.ErrShareNotFound, protocol.ErrorCodeNotFound},
{identity.ErrInvalidPairingCode, protocol.ErrorCodeInvalidCredentials},
{port.ErrNoPassword, protocol.ErrorCodeConflict},
{identity.ErrGitHubLoginExpired, protocol.ErrorCodeInvalidCredentials},
{identity.ErrGitHubMismatch, protocol.ErrorCodeInvalidCredentials},
{identity.ErrGitHubUnverified, protocol.ErrorCodeForbidden},
{identity.ErrGitHubDisabled, protocol.ErrorCodeNotFound},
{port.ErrGitHubTaken, protocol.ErrorCodeConflict},
{port.ErrGitHubAlreadyBound, protocol.ErrorCodeConflict},
}
func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
@@ -294,7 +384,7 @@ func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
}
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: "请求体不能超过 64 KiB"}, true
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: fmt.Sprintf("请求体不能超过 %d KiB", tooLarge.Limit>>10)}, true
}
var roomErr *room.Error
if errors.As(err, &roomErr) {
+59 -2
View File
@@ -22,6 +22,7 @@ import (
"github.com/dihemoe/alisten/internal/app/admin"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/app/realtime"
@@ -38,15 +39,19 @@ const allowedOrigin = "https://allowed.example"
func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string) Services {
music := newCatalog(rooms, 10)
node := porttest.NewRedis().Node("a")
_, gateway := realtime.New(store, rooms, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
_, gateway := realtime.New(store, rooms, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
l := lobby.New(rooms, node)
accounts := identity.NewAccounts(store, store, nil, nil, nil, nil, gateway)
return Services{
Identity: identity.New(store, rooms, store, gateway),
Accounts: accounts,
Lobby: l,
Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
Gateway: gateway,
Catalog: music,
Site: site.New(store),
// 这些测试不发起 GitHub 授权,只读取后台是否配置了 OAuth App。
GitHub: identity.NewGitHub(accounts, store, nil, nil),
}
}
@@ -153,6 +158,58 @@ func TestRequestBodyLimit(t *testing.T) {
}
}
func TestPlaylistBatchBodyLimit(t *testing.T) {
store := porttest.New()
s := services(store, store, "")
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil, nil)
server, _ := start(t, s)
var created protocol.CreateMemberResponse
if err := json.Unmarshal([]byte(send(t, server, http.MethodPost, "/api/members", "{}").body), &created); err != nil {
t.Fatal(err)
}
if err := store.Register(t.Context(), created.Member.ID, "alice", "hash"); err != nil {
t.Fatal(err)
}
auth := []string{"Authorization", "Bearer " + created.MemberToken}
lists, err := store.MemberPlaylists(t.Context(), created.Member.ID)
if err != nil {
t.Fatal(err)
}
favorites := lists[0].ID
other, err := store.CreatePlaylist(t.Context(), created.Member.ID, "通勤", 50)
if err != nil {
t.Fatal(err)
}
tracks := make([]string, 1000)
for i := range tracks {
tracks[i] = fmt.Sprintf(`{"source":"link","id":"https://www.youtube.com/watch?v=%011d&list=PL%032d"}`, i, i)
}
list := "[" + strings.Join(tracks, ",") + "]"
if len(list) <= 64<<10 {
t.Fatalf("1000 首链接只有 %d 字节,测不出 64 KiB 的上限", len(list))
}
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", `{"tracks":`+list+`}`, auth...); got.status != http.StatusNoContent {
t.Errorf("移出 1000 首链接 = %#v,期望成功", got)
}
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/moves", `{"tracks":`+list+`}`, auth...); got.status != http.StatusNoContent {
t.Errorf("移动 1000 首链接 = %#v,期望成功", got)
}
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+other.ID+"/copies", `{"from":"`+favorites+`","tracks":`+list+`}`, auth...); got != (response{status: http.StatusOK, body: `{"added":0}`}) {
t.Errorf("复制 1000 首链接 = %#v,期望成功", got)
}
tooLarge := `{"tracks":[]}` + strings.Repeat(" ", maxBatchBodyBytes)
want := failure(protocol.ErrorCodeInvalidArgument, fmt.Sprintf("请求体不能超过 %d KiB", maxBatchBodyBytes>>10))
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", tooLarge, auth...); got != want {
t.Errorf("超过批量上限的请求体 = %#v,期望 %#v", got, want)
}
want = failure(protocol.ErrorCodeInvalidArgument, "请求体不能超过 64 KiB")
if got := send(t, server, http.MethodPost, "/api/me/playlist-memberships", `{"tracks":`+list+`}`, auth...); got != want {
t.Errorf("其他接口超过 64 KiB 的请求体 = %#v,期望 %#v", got, want)
}
}
func TestMalformedPathParametersAreNotFound(t *testing.T) {
store := porttest.New()
server, logs := start(t, services(store, store, ""))
@@ -298,7 +355,7 @@ func TestAbortHandlerIsLeftToNetHTTP(t *testing.T) {
type brokenProfiles struct{ *porttest.Store }
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string) (member.Member, error) {
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string, *string) (member.Member, error) {
return member.Member{}, errors.New("连接中断")
}
+1 -1
View File
@@ -41,7 +41,7 @@ func (s *server) answer(ctx context.Context, roomID string, data any) (resp any,
func summaries(songs []port.Song) []protocol.TrackSummary {
items := make([]protocol.TrackSummary, len(songs))
for i, s := range songs {
items[i] = protocol.TrackSummary{Track: protocol.Track{Source: protocol.Source(s.Track.Source), ID: s.Track.ID}, Meta: meta(s.Meta)}
items[i] = protocol.TrackSummary{Track: trackDTO(s.Track), Meta: meta(s.Meta)}
}
return items
}
+4
View File
@@ -17,6 +17,10 @@ func (noTracks) PreparedTrack(context.Context, track.Track) (port.PreparedTrack,
return port.PreparedTrack{}, false, nil
}
func (noTracks) TrackMetas(context.Context, []track.Track) (map[track.Track]track.Meta, error) {
return nil, nil
}
func (noTracks) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"net/http"
"net/netip"
"strings"
)
// 只有对端是回环、私有网段或链路本地地址的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理,
// 第一个不受信任的地址就是客户端;全部受信任时取最左端的地址。受信任的代理只会写入合法的地址,
// 遇到不合法的值说明已经到了客户端自己填写的部分,此时改用对端地址。
func clientIP(r *http.Request) string {
peer, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
addr := peer.Addr().Unmap()
if !trustedProxy(addr) {
return addr.String()
}
var hops []string
for _, v := range r.Header.Values("X-Forwarded-For") {
for hop := range strings.SplitSeq(v, ",") {
hops = append(hops, strings.TrimSpace(hop))
}
}
for i := len(hops) - 1; i >= 0; i-- {
hop, err := netip.ParseAddr(hops[i])
if err != nil {
break
}
hop = hop.Unmap()
if i == 0 || !trustedProxy(hop) {
return hop.String()
}
}
return addr.String()
}
func trustedProxy(addr netip.Addr) bool {
return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()
}
+41
View File
@@ -0,0 +1,41 @@
package api
import (
"net/http/httptest"
"testing"
)
func TestClientIP(t *testing.T) {
cases := []struct {
name string
remote string
forwarded []string
want string
}{
{"公网对端不看 X-Forwarded-For", "203.0.113.7:4321", []string{"198.51.100.1"}, "203.0.113.7"},
{"私有网段的代理转发时取公网客户端地址", "10.42.0.153:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"回环对端同样可信", "127.0.0.1:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"IPv6 回环对端同样可信", "[::1]:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"链路本地对端同样可信", "[fe80::1]:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"链式代理跳过右端的私有地址", "127.0.0.1:4321", []string{"203.0.113.7, 10.42.0.1, 192.168.1.2"}, "203.0.113.7"},
{"客户端自己填在左边的地址不采用", "10.42.0.153:4321", []string{"198.51.100.1, 203.0.113.7"}, "203.0.113.7"},
{"多个头按顺序拼接", "10.42.0.153:4321", []string{"198.51.100.1", "203.0.113.7"}, "203.0.113.7"},
{"全部是私有地址时取最左端", "10.42.0.153:4321", []string{"192.168.1.5, 10.42.0.1"}, "192.168.1.5"},
{"没有 X-Forwarded-For 时取对端", "10.42.0.153:4321", nil, "10.42.0.153"},
{"遇到不合法的地址就停下,取对端", "10.42.0.153:4321", []string{"203.0.113.7, unknown, 10.42.0.1"}, "10.42.0.153"},
{"IPv4 映射的 IPv6 对端按 IPv4 处理", "[::ffff:10.42.0.153]:4321", []string{"::ffff:203.0.113.7"}, "203.0.113.7"},
{"IPv4 映射的 IPv6 私有跳按 IPv4 处理", "10.42.0.153:4321", []string{"203.0.113.7, ::ffff:10.42.0.1"}, "203.0.113.7"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = tc.remote
for _, v := range tc.forwarded {
r.Header.Add("X-Forwarded-For", v)
}
if got := clientIP(r); got != tc.want {
t.Errorf("clientIP = %q,期望 %q", got, tc.want)
}
})
}
}
+30 -10
View File
@@ -48,11 +48,7 @@ func (s *server) action(ctx context.Context, roomID string, by member.Member, bo
// 协议解码保证要么只给出 track,要么只给出 source 和 keyword。
func pick(d protocol.QueueAddRequest) realtime.Pick {
if d.Track != nil {
t := track.Track{Source: track.Source(d.Track.Source), ID: d.Track.ID}
if t.Source == track.Link {
t.ID = track.LinkID(t.ID)
}
return realtime.Pick{Track: t, Requester: deref(d.Requester)}
return realtime.Pick{Track: trackOf(*d.Track), Requester: deref(d.Requester)}
}
return realtime.Pick{Source: track.Source(*d.Source), Keyword: *d.Keyword, Requester: deref(d.Requester)}
}
@@ -145,18 +141,42 @@ func (s *server) mediaURL(t track.Track) string {
return "/media/" + string(t.Source) + "/" + url.PathEscape(t.ID)
}
// 链接按点歌时的写法规范化:曲目表和歌单里的链接曲目都用规范化之后的 ID。
func trackOf(d protocol.Track) track.Track {
t := track.Track{Source: track.Source(d.Source), ID: d.ID}
if t.Source == track.Link {
t.ID = track.LinkID(t.ID)
}
return t
}
// 封面在对象存储中的曲目,对外的封面地址由公开地址拼出。
func (s *server) trackMeta(m track.Meta) protocol.TrackMeta {
d := meta(m)
d.CoverURL = s.coverURL(m.CoverURL, m.CoverKey)
return d
}
func (s *server) coverURL(url string, key track.ObjectKey) string {
if key != "" {
return s.StorageURL + "/" + string(key)
}
return url
}
func trackDTO(t track.Track) protocol.Track {
return protocol.Track{Source: protocol.Source(t.Source), ID: t.ID}
}
func (s *server) entry(e room.Entry) protocol.Entry {
d := protocol.Entry{
ID: strconv.FormatUint(uint64(e.ID), 10),
Track: protocol.Track{Source: protocol.Source(e.Track.Source), ID: e.Track.ID},
Meta: meta(e.Meta),
Track: trackDTO(e.Track),
Meta: s.trackMeta(e.Meta),
Auto: e.Auto,
Likes: int64(e.Likes),
Status: protocol.EntryStatusPreparing,
}
if e.Meta.CoverKey != "" {
d.Meta.CoverURL = s.StorageURL + "/" + string(e.Meta.CoverKey)
}
if !e.Auto {
by := memberDTO(e.By)
d.By = &by
+4 -6
View File
@@ -2,7 +2,6 @@ package api
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
@@ -10,6 +9,7 @@ import (
"strconv"
"time"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/protocol"
)
@@ -23,7 +23,7 @@ const (
var streamCloses = map[realtime.CloseReason]streamClose{
realtime.CloseRestart: {Code: protocol.CloseServiceRestart, Message: "服务端实例退出或订阅中断,请重新连接"},
realtime.CloseRevoked: {Code: protocol.CloseUnauthorized, Message: "凭证已失效:房间设置或修改了密码,或者 API Key 已重置"},
realtime.CloseRevoked: {Code: protocol.CloseUnauthorized, Message: "凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"},
realtime.CloseRoomGone: {Code: protocol.CloseNotFound, Message: "房间已关闭或被删除"},
realtime.CloseOverflow: {Code: protocol.CloseSendQueueFull, Message: "积压的事件过多,请重新连接"},
// 不是 1012:共用一把 Key 的两个 bot 看到它就该停下,而不是重连之后互相顶掉。
@@ -41,7 +41,7 @@ type streamClose struct {
func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
// 打开事件流的期限与 WebSocket 的握手相同,打开之后的推送不设期限。
setup, cancel := context.WithTimeout(r.Context(), helloTimeout)
ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, s.observe)
ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, maxBodyBytes, s.observe)
cancel()
if err != nil {
s.fail(ctx, w, r, err)
@@ -81,9 +81,7 @@ func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
// 确认票据失败或 panic 时都要退订,否则事件流的名额和房间频道一直被占着。
func (s *server) observe(ctx context.Context, c call) (any, error) {
// 内存中只留凭证的哈希。
credential := sha256.Sum256([]byte(c.ticket))
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.bot, string(credential[:]))
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.memberSession, c.bot, string(identity.TicketCredential(c.ticket)))
if err != nil {
return nil, err
}
+35 -1
View File
@@ -145,11 +145,45 @@ func TestRoomEvents(t *testing.T) {
if _, err := f.admin.ResetRoomAPIKey(t.Context(), hall); err != nil {
t.Fatal(err)
}
checkEvent(t, r, "event: close", `data: {"code":4401,"message":"凭证已失效:房间设置或修改了密码,或者 API Key 已重置"}`)
checkEvent(t, r, "event: close", `data: {"code":4401,"message":"凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"}`)
checkEnded(t, r)
})
}
// 网关定期到存储中核对连接所用的凭证:事件流登记的凭证要与存储中的票据哈希相同,否则仍然有效的事件流也会被断开。
func TestRoomEventsSurviveCredentialCheck(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
f := newWSFixture(t, store, store, store)
hall, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "大厅"})
if err != nil {
t.Fatal(err)
}
key, err := f.admin.ResetRoomAPIKey(t.Context(), hall)
if err != nil {
t.Fatal(err)
}
_, _, ticket := f.enter(t, hall)
bot, member := f.stream(t, key), f.stream(t, ticket)
readEvent(t, bot)
readEvent(t, member)
// 心跳每 25 秒一次,核对每 30 秒一次:第二次心跳之前已经核对过一次。
for range 2 {
checkEvent(t, bot, ": ping")
checkEvent(t, member, ": ping")
}
// 不经过 admin 重置,就没有断开的通知,只能靠下一次核对断开。
_, fresh := identity.NewAPIKey()
if err := store.ResetRoomAPIKey(t.Context(), hall, fresh); err != nil {
t.Fatal(err)
}
checkEvent(t, bot, "event: close", `data: {"code":4401,"message":"凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"}`)
checkEnded(t, bot)
checkEvent(t, member, ": ping")
})
}
func TestRoomEventsCloseWithTheRoom(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
+140
View File
@@ -0,0 +1,140 @@
package api
import (
"context"
"errors"
"net/http"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/protocol"
)
// 用户在 GitHub 上授权之后,浏览器跳到这里:网页的 origin 加上它,就是 OAuth App 中登记的回调地址。
const GitHubCallbackPath = "/api/auth/github/callback"
// 回调处理完之后的落地页,结果放在片段里。App 用系统浏览器授权,跳到 App 的自定义 scheme 才能回到 App。
// 自定义 scheme 可能被别的 App 抢注,截获的兑换码却换不到身份:它 1 分钟内有效、只能用一次,兑换时要带上发起授权的客户端本机保存的
// nonce,并且必须由发起授权的同一个成员调用。
const (
githubWebLanding = "/auth/github"
githubAppLanding = "moe.dihe.alisten:/auth/github"
)
func (s *server) startGithubLogin(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeStartGithubLoginRequest(c.body)
if err != nil {
return nil, err
}
intent := port.GitHubLogin
switch req.Intent {
case protocol.GitHubIntentBind:
intent = port.GitHubBind
case protocol.GitHubIntentVerify:
intent = port.GitHubVerify
}
client := port.GitHubWeb
if req.Client != nil && *req.Client == protocol.GitHubClientApp {
client = port.GitHubApp
}
url, err := s.GitHub.Authorize(ctx, c.member.ID, c.clientIP, intent, client, req.Nonce, deref(req.CurrentPassword))
if err != nil {
return nil, err
}
return protocol.StartGithubLoginResponse{URL: url}, nil
}
func (s *server) finishGithubLogin(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeFinishGithubLoginRequest(c.body)
if err != nil {
return nil, err
}
result, err := s.GitHub.Exchange(ctx, c.member, bearer(c.r), req.Code, req.Nonce)
if err != nil {
return nil, err
}
resp := protocol.FinishGithubLoginResponse{Member: memberDTO(result.Member), Account: accountDTO(result.Account)}
if result.Session != nil {
resp.MemberToken = &result.Session.MemberToken
}
return resp, nil
}
func (s *server) unbindGithub(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeUnbindGithubRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.GitHub.Unbind(ctx, c.member.ID, req.CurrentPassword)
}
func (s *server) adminGetGithubCredential(ctx context.Context, _ call) (any, error) {
clientID, err := s.GitHub.ClientID(ctx)
if err != nil {
return nil, err
}
return protocol.AdminGetGithubCredentialResponse{ClientID: optional(clientID), ClientSecretSet: clientID != "", CallbackURL: s.PublicURL + GitHubCallbackPath}, nil
}
func (s *server) adminSetGithubCredential(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminSetGithubCredentialRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.GitHub.SetOAuthApp(ctx, port.GitHubOAuthApp{ClientID: req.ClientID, ClientSecret: req.ClientSecret})
}
func (s *server) adminDeleteGithubCredential(ctx context.Context, _ call) (any, error) {
return nil, s.GitHub.DeleteOAuthApp(ctx)
}
// 浏览器从 GitHub 跳回来,换到兑换码之后跳到发起授权的客户端的落地页。结果放在片段里,不会出现在之后的请求和 Referer 中;
// 这个地址的查询串里有授权码,所以响应也不让浏览器把它作为 Referer 发出去。跳转目标只看 state 对应的记录,不看回调地址中的参数,
// 否则就成了开放重定向。
func (s *server) githubCallback(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
defer cancel()
q := r.URL.Query()
var (
client port.GitHubClient
fragment string
err error
)
if reason := q.Get("error"); reason != "" {
fragment = "error=denied"
if reason != "access_denied" {
s.logger.WarnContext(ctx, "GitHub 拒绝了授权请求", "reason", reason, "description", q.Get("error_description"))
fragment = "error=failed"
}
client, err = s.GitHub.Cancel(ctx, q.Get("state"))
if err != nil && !errors.Is(err, identity.ErrGitHubRequestExpired) {
s.logger.ErrorContext(ctx, "作废 GitHub 授权的 state 失败", "error", err)
}
} else {
var code string
client, code, err = s.GitHub.Callback(ctx, q.Get("state"), q.Get("code"))
switch {
case err == nil:
fragment = "code=" + code
case errors.Is(err, identity.ErrGitHubRequestExpired):
fragment = "error=expired"
case errors.Is(err, identity.ErrGitHubDisabled):
fragment = "error=failed"
case errors.Is(err, identity.ErrGitHubFailed):
s.logger.WarnContext(ctx, "没有从 GitHub 换到用户", "error", err)
fragment = "error=failed"
default:
s.logger.ErrorContext(ctx, "处理 GitHub 的回调失败", "error", err)
fragment = "error=failed"
}
}
landing := githubWebLanding
if client == port.GitHubApp {
landing = githubAppLanding
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
// 不用 http.Redirect:它会把片段当作路径的一部分清理。
w.Header().Set("Location", landing+"#"+fragment)
w.WriteHeader(http.StatusFound)
}
+19 -6
View File
@@ -13,11 +13,12 @@ import (
"github.com/dihemoe/alisten/protocol"
)
// 路径中的房间 ID 和别名不经过协议解码。格式不正确的房间 ID 或别名一定不存在,其中含有 U+0000 等字符的还会让
// 路径中的房间 ID、别名和用户名不经过协议解码。格式不正确的房间 ID、别名或用户名一定不存在,其中含有 U+0000 等字符的还会让
// PostgreSQL 报错,所以直接按不存在处理。
var (
uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
usernamePattern = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
)
func (s *server) createMember(ctx context.Context, c call) (any, error) {
@@ -31,8 +32,20 @@ func (s *server) createMember(ctx context.Context, c call) (any, error) {
return protocol.CreateMemberResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member)}, nil
}
func (s *server) getMe(_ context.Context, c call) (any, error) {
return protocol.GetMeResponse{Member: memberDTO(c.member)}, nil
func (s *server) getMe(ctx context.Context, c call) (any, error) {
resp := protocol.GetMeResponse{Member: memberDTO(c.member)}
account, registered, err := s.Accounts.Account(ctx, c.member.ID)
if err != nil {
return nil, err
}
if registered {
dto := accountDTO(account)
resp.Account = &dto
}
if resp.RecordListens, err = s.Listens.Recording(ctx, c.member.ID); err != nil {
return nil, err
}
return resp, nil
}
func (s *server) updateMe(ctx context.Context, c call) (any, error) {
@@ -88,7 +101,7 @@ func (s *server) createTicket(ctx context.Context, c call) (any, error) {
if err != nil {
return nil, err
}
ticket, err := s.Identity.IssueTicket(ctx, c.member.ID, id, deref(req.Password))
ticket, err := s.Identity.IssueTicket(ctx, c.member.ID, bearer(c.r), id, deref(req.Password))
if err != nil {
return nil, err
}
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"context"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) listMyListens(ctx context.Context, c call) (any, error) {
listens, err := s.Listens.List(ctx, c.member.ID)
if err != nil {
return nil, err
}
items := make([]protocol.MyListen, len(listens))
for i, l := range listens {
items[i] = protocol.MyListen{Track: trackDTO(l.Track), Meta: s.trackMeta(l.Meta), HeardAtMs: l.HeardAt.UnixMilli()}
}
return protocol.ListMyListensResponse{Items: items}, nil
}
func (s *server) removeMyListen(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRemoveMyListenRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Listens.Remove(ctx, c.member.ID, trackOf(req.Track))
}
func (s *server) clearMyListens(ctx context.Context, c call) (any, error) {
return nil, s.Listens.Clear(ctx, c.member.ID)
}
func (s *server) enableListenRecording(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeEnableListenRecordingRequest(c.body); err != nil {
return nil, err
}
return nil, s.Listens.SetRecording(ctx, c.member.ID, true)
}
func (s *server) disableListenRecording(ctx context.Context, c call) (any, error) {
return nil, s.Listens.SetRecording(ctx, c.member.ID, false)
}
+48 -14
View File
@@ -3,10 +3,9 @@ package api
import (
"context"
"net/http"
"regexp"
"time"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
"github.com/dihemoe/alisten/protocol"
)
@@ -43,33 +42,68 @@ func (s *server) redirectTarget(ctx context.Context, r *http.Request, t track.Tr
return s.Playback.URL(ctx, t, time.Now())
}
var mediaKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
var mediaSorts = map[protocol.MediaSort]port.MediaSort{
protocol.MediaSortStored: port.MediaByStored,
protocol.MediaSortIdle: port.MediaByIdle,
protocol.MediaSortSize: port.MediaBySize,
}
func (s *server) adminListMedia(ctx context.Context, _ call) (any, error) {
list, err := s.Media.List(ctx)
func (s *server) adminListMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminListMediaRequest(c.body)
if err != nil {
return nil, err
}
items := make([]protocol.StoredMedia, len(list))
for i, m := range list {
q := port.MediaQuery{Page: 1, Sort: port.MediaByStored}
if req.Page != nil {
q.Page = int(*req.Page)
}
if req.Sort != nil {
q.Sort = mediaSorts[*req.Sort]
}
if req.Q != nil {
q.Title = *req.Q
}
page, err := s.Media.List(ctx, q)
if err != nil {
return nil, err
}
items := make([]protocol.StoredMedia, len(page.Items))
for i, m := range page.Items {
link := m.Link
// 后台把 link 当作链接打开,B 站的歌给出视频页面。
if m.Source == track.Bilibili {
link = track.BilibiliPageURL(m.Link)
}
items[i] = protocol.StoredMedia{Key: m.Key, Source: protocol.Source(m.Source), Link: link, Title: m.Title, Size: m.Size, StoredAtMs: m.StoredAt.UnixMilli()}
items[i] = protocol.StoredMedia{
Key: m.Key,
Source: protocol.Source(m.Source),
Link: link,
Title: m.Title,
Artist: m.Artist,
CoverURL: s.coverURL(m.CoverURL, m.CoverKey),
Size: m.Size,
StoredAtMs: m.StoredAt.UnixMilli(),
}
if m.LastPlayedAt != nil {
played := m.LastPlayedAt.UnixMilli()
items[i].LastPlayedAtMs = &played
}
}
return protocol.AdminListMediaResponse{Items: items}, nil
return protocol.AdminListMediaResponse{Items: items, Total: int64(page.Total), TotalBytes: page.TotalBytes, Page: int64(q.Page), PageSize: port.MediaPageSize}, nil
}
func (s *server) adminDeleteMedia(ctx context.Context, c call) (any, error) {
key := c.r.PathValue("key")
if !mediaKeyPattern.MatchString(key) {
return nil, media.ErrNotFound
func (s *server) adminRemoveMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminRemoveMediaRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Media.Delete(ctx, key)
keys := make([]string, len(req.Media))
for i, m := range req.Media {
keys[i] = m.Key
}
deleted, inUse, err := s.Media.DeleteMany(ctx, keys)
if err != nil {
return nil, err
}
return protocol.AdminRemoveMediaResponse{Deleted: deleted, InUse: inUse}, nil
}
+44 -44
View File
@@ -24,63 +24,63 @@ import (
"github.com/dihemoe/alisten/protocol"
)
type storedMedia struct {
items []port.StoredMedia
used bool
}
func (m *storedMedia) ListStoredMedia(context.Context) ([]port.StoredMedia, error) {
return m.items, nil
}
func (m *storedMedia) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
for _, item := range m.items {
if item.Key == key {
return item, true, nil
}
}
return port.StoredMedia{}, false, nil
}
func (m *storedMedia) DeleteStoredMedia(context.Context, string) error {
m.items = nil
return nil
}
func (m *storedMedia) TrackInUse(context.Context, track.Track) (bool, error) {
return m.used, nil
}
func TestAdminMedia(t *testing.T) {
key := media.Key("https://example.com/a.mp3")
other := media.Key("https://example.com/b.mp3")
video := media.Key("BV1xx411c7mD/2")
played := time.UnixMilli(1760000000456)
stored := &storedMedia{items: []port.StoredMedia{
{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Size: 12290, StoredAt: time.UnixMilli(1760000000123), LastPlayedAt: &played},
{Key: media.Key("https://example.com/b.mp3"), Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, StoredAt: time.UnixMilli(1760000000789)},
{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
}}
library := porttest.NewMediaLibrary(
port.StoredMedia{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Artist: "周杰伦", Size: 12290, CoverKey: track.ObjectKey("link/" + key + "/cover.jpg"), StoredAt: time.UnixMilli(1760000000123)},
port.StoredMedia{Key: other, Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, CoverURL: "https://example.com/b.jpg", StoredAt: time.UnixMilli(1760000000789), LastPlayedAt: &played},
port.StoredMedia{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Artist: "某个 UP 主", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
)
store := porttest.New()
s := services(store, store, "admin-secret")
s.Media = media.NewLibrary(stored, stored, porttest.NewMediaStore())
s.StorageURL = "https://alisten.example/storage/alisten"
s.Media = media.NewLibrary(library, inRooms{{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}}, porttest.NewMediaStore())
server, _ := start(t, s)
admin := []string{"Authorization", "Bearer admin-secret"}
want := response{status: http.StatusOK, body: `{"items":[` +
`{"key":"` + key + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"},` +
`{"key":"` + media.Key("https://example.com/b.mp3") + `","lastPlayedAtMs":null,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
`{"key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"}]}`}
if got := send(t, server, http.MethodGet, "/api/admin/media", "", admin...); got != want {
t.Errorf("列出已存储的媒体 = %#v\n期望 %#v", got, want)
`{"artist":"某个 UP 主","coverUrl":"","key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"},` +
`{"artist":"","coverUrl":"https://example.com/b.jpg","key":"` + other + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
`{"artist":"周杰伦","coverUrl":"https://alisten.example/storage/alisten/link/` + key + `/cover.jpg","key":"` + key + `","lastPlayedAtMs":null,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"}],` +
`"page":1,"pageSize":50,"total":3,"totalBytes":12293}`}
if got := send(t, server, http.MethodPost, "/api/admin/media/queries", "{}", admin...); got != want {
t.Errorf("按默认参数列出已存储的媒体 = %#v\n期望 %#v", got, want)
}
for _, tt := range []struct {
body string
page int64
want []string
total, size int64
}{
{`{"sort": "stored"}`, 1, []string{video, other, key}, 3, 12293},
{`{"sort": "idle"}`, 1, []string{video, key, other}, 3, 12293},
{`{"sort": "size", "q": "晴天"}`, 1, []string{key, video}, 2, 12292},
{`{"page": 2}`, 2, []string{}, 3, 12293},
} {
got := send(t, server, http.MethodPost, "/api/admin/media/queries", tt.body, admin...)
var page protocol.AdminListMediaResponse
if err := json.Unmarshal([]byte(got.body), &page); err != nil {
t.Fatalf("%s = %#v", tt.body, got)
}
keys := make([]string, len(page.Items))
for i, m := range page.Items {
keys[i] = m.Key
}
if got.status != http.StatusOK || !slices.Equal(keys, tt.want) || page.Page != tt.page || page.Total != tt.total || page.TotalBytes != tt.size {
t.Errorf("%s = %#v,期望第 %d 页 %q,共 %d 首 %d 字节", tt.body, got, tt.page, tt.want, tt.total, tt.size)
}
}
stored.used = true
if got, want := send(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...), failure(protocol.ErrorCodeConflict, "这首歌还在房间的播放列表中,不能删除"); got != want {
t.Errorf("删除仍被房间引用的媒体 = %#v,期望 %#v", got, want)
if got := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+key+`"}, {"key": "not-a-key"}]}`, admin...); got.status != http.StatusBadRequest {
t.Errorf("批量删除里有格式不对的 key = %#v,期望 400、一首也不删", got)
}
if got, want := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+video+`"}, {"key": "`+key+`"}, {"key": "`+media.Key("https://example.com/missing.mp3")+`"}]}`, admin...),
(response{status: http.StatusOK, body: `{"deleted":["` + key + `"],"inUse":["` + video + `"]}`}); got != want {
t.Errorf("批量删除 = %#v,期望 %#v", got, want)
}
stored.used = false
resp, header := exchange(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...)
checkNoContent(t, "删除媒体", resp, header)
}
type playURL map[string]string
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"testing"
"time"
"rsc.io/qr"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/protocol"
)
type unlimited struct{}
func (unlimited) Take(context.Context, string, int, time.Duration) (time.Duration, error) {
return 0, nil
}
// 二维码的内容是网页上兑换配对码的地址,配对码放在片段里。
func TestPairingCodeQRImage(t *testing.T) {
store := porttest.New()
s := services(store, store, "")
s.Accounts = identity.NewAccounts(store, store, unlimited{}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), s.Gateway)
s.PublicURL = "https://alisten.example"
server, _ := start(t, s)
token := memberToken(t, server)
auth := []string{"Authorization", "Bearer " + token}
if got := send(t, server, http.MethodPost, "/api/account", `{"username":"alice","password":"correct horse"}`, auth...); got.status != http.StatusCreated {
t.Fatalf("注册 = %#v", got)
}
got := send(t, server, http.MethodPost, "/api/account/pairing-codes", "{}", auth...)
var created protocol.CreatePairingCodeResponse
if err := json.Unmarshal([]byte(got.body), &created); err != nil || got.status != http.StatusCreated {
t.Fatalf("生成配对码 = %#v", got)
}
code, err := qr.Encode("https://alisten.example/pair#"+created.Code, qr.M)
if err != nil {
t.Fatal(err)
}
if want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()); created.QrImage != want {
t.Errorf("二维码 = %.60q…,期望内容为 https://alisten.example/pair#%s 的 PNG", created.QrImage, created.Code)
}
}
+242
View File
@@ -0,0 +1,242 @@
package api
import (
"context"
"time"
"github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) playlistDTO(p port.PlaylistRecord) protocol.MyPlaylist {
var cover *string
if p.Cover != nil {
url := s.coverURL(p.Cover.URL, p.Cover.Key)
cover = &url
}
return protocol.MyPlaylist{
ID: p.ID,
Name: p.Name,
Favorites: p.Favorites,
TrackCount: int64(p.TrackCount),
CreatedAtMs: p.CreatedAt.UnixMilli(),
UpdatedAtMs: p.UpdatedAt.UnixMilli(),
CoverURL: cover,
ShareID: optional(p.ShareID),
}
}
func (s *server) playlistItemDTO(item port.PlaylistItem) protocol.MyPlaylistItem {
return protocol.MyPlaylistItem{
Track: trackDTO(item.Track),
Meta: s.trackMeta(item.Meta),
}
}
func (s *server) listMyPlaylists(ctx context.Context, c call) (any, error) {
lists, err := s.Playlists.List(ctx, c.member.ID)
if err != nil {
return nil, err
}
items := make([]protocol.MyPlaylist, len(lists))
for i, p := range lists {
items[i] = s.playlistDTO(p)
}
return protocol.ListMyPlaylistsResponse{Items: items}, nil
}
func (s *server) createMyPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeCreateMyPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, err := s.Playlists.Create(ctx, c.member.ID, req.Name)
if err != nil {
return nil, err
}
return s.playlistDTO(p), nil
}
func (s *server) renameMyPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRenameMyPlaylistRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Playlists.Rename(ctx, c.member.ID, c.r.PathValue("id"), req.Name)
}
func (s *server) deleteMyPlaylist(ctx context.Context, c call) (any, error) {
return nil, s.Playlists.Delete(ctx, c.member.ID, c.r.PathValue("id"))
}
func (s *server) listMyPlaylistItems(ctx context.Context, c call) (any, error) {
list, err := s.Playlists.Items(ctx, c.member.ID, c.r.PathValue("id"))
if err != nil {
return nil, err
}
items := make([]protocol.MyPlaylistItem, len(list))
for i, item := range list {
items[i] = s.playlistItemDTO(item)
}
return protocol.ListMyPlaylistItemsResponse{Items: items}, nil
}
func (s *server) addMyPlaylistItem(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAddMyPlaylistItemRequest(c.body)
if err != nil {
return nil, err
}
item, err := s.Playlists.Add(ctx, c.member.ID, c.r.PathValue("id"), trackOf(req.Track))
if err != nil {
return nil, err
}
return protocol.AddMyPlaylistItemResponse{Item: s.playlistItemDTO(item)}, nil
}
func tracksOf(in []protocol.Track) []track.Track {
out := make([]track.Track, len(in))
for i, t := range in {
out[i] = trackOf(t)
}
return out
}
func (s *server) removeMyPlaylistItems(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRemoveMyPlaylistItemsRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Playlists.Remove(ctx, c.member.ID, c.r.PathValue("id"), tracksOf(req.Tracks))
}
func (s *server) listMyTrackPlaylists(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeListMyTrackPlaylistsRequest(c.body)
if err != nil {
return nil, err
}
ids, err := s.Playlists.Containing(ctx, c.member.ID, trackOf(req.Track))
if err != nil {
return nil, err
}
return protocol.ListMyTrackPlaylistsResponse{PlaylistIds: ids}, nil
}
func (s *server) copyMyPlaylistItems(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeCopyMyPlaylistItemsRequest(c.body)
if err != nil {
return nil, err
}
n, err := s.Playlists.Copy(ctx, c.member.ID, req.From, c.r.PathValue("id"), tracksOf(req.Tracks))
if err != nil {
return nil, err
}
return protocol.CopyMyPlaylistItemsResponse{Added: int64(n)}, nil
}
func (s *server) moveMyPlaylistItems(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeMoveMyPlaylistItemsRequest(c.body)
if err != nil {
return nil, err
}
var before *track.Track
if req.Before != nil {
t := trackOf(*req.Before)
before = &t
}
return nil, s.Playlists.Move(ctx, c.member.ID, c.r.PathValue("id"), tracksOf(req.Tracks), before)
}
func (s *server) importMyPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeImportMyPlaylistRequest(c.body)
if err != nil {
return nil, err
}
tracks := make([]playlist.ImportedTrack, len(req.Tracks))
for i, t := range req.Tracks {
tracks[i] = playlist.ImportedTrack{
Track: trackOf(t.Track),
Title: t.Title,
Artist: deref(t.Artist),
Album: deref(t.Album),
CoverURL: deref(t.CoverURL),
PageURL: deref(t.PageURL),
Duration: time.Duration(t.DurationMs) * time.Millisecond,
}
}
p, n, err := s.Playlists.ImportFile(ctx, c.member.ID, req.Name, tracks)
if err != nil {
return nil, err
}
return protocol.ImportMyPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil
}
func (s *server) resolveMyPlaylistLink(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeResolveMyPlaylistLinkRequest(c.body)
if err != nil {
return nil, err
}
source, id, err := s.Playlists.ResolveLink(ctx, c.member.ID, req.Link)
if err != nil {
return nil, err
}
return protocol.ResolveMyPlaylistLinkResponse{Source: protocol.Source(source), PlaylistID: id}, nil
}
func (s *server) importMyPlatformPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeImportMyPlatformPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, n, err := s.Playlists.ImportPlatform(ctx, c.member.ID, req.Name, track.Source(req.Source), req.PlaylistID)
if err != nil {
return nil, err
}
return protocol.ImportMyPlatformPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil
}
func (s *server) shareMyPlaylist(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeShareMyPlaylistRequest(c.body); err != nil {
return nil, err
}
shareID, err := s.Playlists.Share(ctx, c.member.ID, c.r.PathValue("id"))
if err != nil {
return nil, err
}
return protocol.ShareMyPlaylistResponse{ShareID: shareID}, nil
}
func (s *server) unshareMyPlaylist(ctx context.Context, c call) (any, error) {
return nil, s.Playlists.Unshare(ctx, c.member.ID, c.r.PathValue("id"))
}
func (s *server) getSharedPlaylist(ctx context.Context, c call) (any, error) {
p, err := s.Playlists.Shared(ctx, c.r.PathValue("shareId"))
if err != nil {
return nil, err
}
items := make([]protocol.MyPlaylistItem, len(p.Items))
for i, item := range p.Items {
items[i] = s.playlistItemDTO(item)
}
return protocol.SharedPlaylist{
Name: p.Name,
Favorites: p.Favorites,
OwnerName: p.OwnerName,
OwnerAvatarHash: p.OwnerAvatarHash,
Items: items,
}, nil
}
func (s *server) copySharedPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeCopySharedPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, err := s.Playlists.CopyShared(ctx, c.member.ID, c.r.PathValue("shareId"), req.Name)
if err != nil {
return nil, err
}
return s.playlistDTO(p), nil
}
+10 -3
View File
@@ -87,15 +87,22 @@ func qrLogin(session port.QRLoginSession) (protocol.QRLogin, error) {
if session.LoginURL == "" {
return protocol.QRLogin{}, fmt.Errorf("音源没有给出二维码图片和登录链接:%w", port.ErrProviderFailed)
}
code, err := qr.Encode(session.LoginURL, qr.M)
if err != nil {
var err error
if image, err = qrDataURI(session.LoginURL); err != nil {
return protocol.QRLogin{}, fmt.Errorf("把登录链接编码成二维码:%w:%w", err, port.ErrProviderFailed)
}
image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())
}
return protocol.QRLogin{Key: session.Key, QrImage: image, ExpiresAtMs: session.ExpiresAt.UnixMilli()}, nil
}
func qrDataURI(text string) (string, error) {
code, err := qr.Encode(text, qr.M)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()), nil
}
func qrLoginResult(r port.QRLoginResult) protocol.QRLoginResult {
return protocol.QRLoginResult{Status: protocol.QRLoginStatus(r.Status), Message: r.Message}
}
+3 -3
View File
@@ -43,7 +43,7 @@ func newRoomFixture(t *testing.T) roomFixture {
store := porttest.New()
music := newCatalog(store, 10)
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(store, store, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
@@ -232,7 +232,7 @@ func TestRoomRequestOfDisconnectedClientIsNotCounted(t *testing.T) {
func TestRoomListingShowsOnlineCount(t *testing.T) {
f := newRoomFixture(t)
id, m, _ := f.enter(t)
session, err := f.gateway.Join(t.Context(), id, m)
session, err := f.gateway.Join(t.Context(), id, m, nil, nil)
if err != nil {
t.Fatal(err)
}
@@ -252,7 +252,7 @@ func TestProfileChangeReachesRooms(t *testing.T) {
session := decode[protocol.CreateMemberResponse](t, send(t, f.server, http.MethodPost, "/api/members", "{}"))
created := decode[protocol.CreateRoomResponse](t, send(t, f.server, http.MethodPost, "/api/rooms", `{"name":"临时","description":""}`))
m := member.Member{ID: session.Member.ID, Name: session.Member.Name}
s, err := f.gateway.Join(t.Context(), created.ID, m)
s, err := f.gateway.Join(t.Context(), created.ID, m, nil, nil)
if err != nil {
t.Fatal(err)
}
+17 -2
View File
@@ -7,11 +7,15 @@ import (
)
func (s *server) getSite(ctx context.Context, _ call) (any, error) {
name, err := s.Site.BrandName(ctx)
settings, err := s.Site.Settings(ctx)
if err != nil {
return nil, err
}
return protocol.GetSiteResponse{BrandName: name}, nil
githubClientID, err := s.GitHub.ClientID(ctx)
if err != nil {
return nil, err
}
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen, GithubLogin: githubClientID != ""}, nil
}
func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
@@ -25,3 +29,14 @@ func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
}
return protocol.AdminUpdateSiteResponse{BrandName: name}, nil
}
func (s *server) adminOpenRegistration(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeAdminOpenRegistrationRequest(c.body); err != nil {
return nil, err
}
return nil, s.Site.SetRegistrationOpen(ctx, true)
}
func (s *server) adminCloseRegistration(ctx context.Context, _ call) (any, error) {
return nil, s.Site.SetRegistrationOpen(ctx, false)
}
+6 -3
View File
@@ -15,14 +15,17 @@ func TestSite(t *testing.T) {
brandName := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `"}`}
}
site := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","githubLogin":false,"registrationOpen":true}`}
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("Alisten") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("Alisten") {
t.Errorf("没有设置过时 GET /api/site = %#v,期望默认的 Alisten", got)
}
if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":" 周末听歌会 "}`, adminHeader...); got != brandName("周末听歌会") {
t.Errorf("PATCH /api/admin/site = %#v,期望返回去掉首尾空白之后存下的名字", got)
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
t.Errorf("修改之后 GET /api/site = %#v,期望去掉首尾空白的新名字", got)
}
@@ -36,7 +39,7 @@ func TestSite(t *testing.T) {
if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":"别的名字"}`); got != unauthorized {
t.Errorf("不带管理 token 的 PATCH = %#v,期望 %#v", got, unauthorized)
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
t.Errorf("被拒绝的修改之后 GET /api/site = %#v,期望不变", got)
}
if logs.Len() != 0 {
+2 -2
View File
@@ -134,7 +134,7 @@ func newWSFixtureWithTickets(t *testing.T, store *porttest.Store, tickets port.T
music := newCatalog(directory, 10)
node := porttest.NewRedis().Node("a")
preparer := porttest.NewPreparer()
host, gateway := realtime.New(snapshots, store, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(snapshots, store, store, store, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
@@ -191,7 +191,7 @@ func (f *wsFixture) enter(t *testing.T, roomID string) (string, member.Member, s
t.Fatal(err)
}
}
ticket, err := f.identity.IssueTicket(t.Context(), session.Member.ID, roomID, "")
ticket, err := f.identity.IssueTicket(t.Context(), session.Member.ID, session.MemberToken, roomID, "")
if err != nil {
t.Fatal(err)
}
+139
View File
@@ -0,0 +1,139 @@
package github
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.GitHubOAuth = (*OAuth)(nil)
const (
timeout = 10 * time.Second
// GitHub 的这两个响应都只有几百字节。
maxResponseBytes = 64 << 10
)
type Endpoint struct {
AuthorizeURL string
TokenURL string
UserURL string
}
var GitHubCom = Endpoint{
AuthorizeURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
UserURL: "https://api.github.com/user",
}
type Config struct {
RedirectURL string
Endpoint Endpoint
}
type OAuth struct {
client *http.Client
cfg Config
}
func New(client *http.Client, cfg Config) *OAuth {
return &OAuth{client: client, cfg: cfg}
}
func (o *OAuth) AuthorizeURL(app port.GitHubOAuthApp, state, verifier string) string {
challenge := sha256.Sum256([]byte(verifier))
q := url.Values{
"client_id": {app.ClientID},
"redirect_uri": {o.cfg.RedirectURL},
"state": {state},
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
"code_challenge_method": {"S256"},
}
return o.cfg.Endpoint.AuthorizeURL + "?" + q.Encode()
}
func (o *OAuth) UserID(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
token, err := o.token(ctx, app, code, verifier)
if err != nil {
return 0, fmt.Errorf("向 GitHub 换取令牌:%w", err)
}
id, err := o.user(ctx, token)
if err != nil {
return 0, fmt.Errorf("读取 GitHub 用户:%w", err)
}
return id, nil
}
func (o *OAuth) token(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (string, error) {
form := url.Values{"client_id": {app.ClientID}, "client_secret": {app.ClientSecret}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {o.cfg.RedirectURL}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.cfg.Endpoint.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
// 授权码错误或过期时,GitHub 也以 200 返回,错误放在响应体的 error 中。
var body struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := o.do(req, &body); err != nil {
return "", err
}
if body.Error != "" {
return "", fmt.Errorf("GitHub 拒绝了授权码:%s(%s)", body.Error, body.ErrorDescription)
}
if body.AccessToken == "" {
return "", fmt.Errorf("GitHub 没有给出令牌")
}
return body.AccessToken, nil
}
func (o *OAuth) user(ctx context.Context, token string) (int64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, o.cfg.Endpoint.UserURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
// GitHub 的 REST API 拒绝不带 User-Agent 的请求。
req.Header.Set("User-Agent", "alisten")
var body struct {
ID int64 `json:"id"`
}
if err := o.do(req, &body); err != nil {
return 0, err
}
if body.ID <= 0 {
return 0, fmt.Errorf("GitHub 返回的用户 ID 不是正数:%d", body.ID)
}
return body.ID, nil
}
func (o *OAuth) do(req *http.Request, v any) error {
resp, err := o.client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub 返回 HTTP %d", resp.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(v); err != nil {
return fmt.Errorf("解码 GitHub 的响应:%w", err)
}
return nil
}
+170
View File
@@ -0,0 +1,170 @@
package github
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
const (
verifier = "dBjftJeZ4CVP-mJ92K1D5gV9dxWXJyqi5aZoxwYkrUM"
challenge = "kXQYIXxOaWujAPbjqX_yBdiwMfNi4RzDNMDwDNpE7QQ"
)
var app = port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}
// 假的 GitHub:授权码 good 签发时绑定了 challenge,带上 verifier 才能换到令牌 token-1,令牌 token-1 对应用户 42。
type fakeGitHub struct {
token func(w http.ResponseWriter, r *http.Request)
user func(w http.ResponseWriter, r *http.Request)
}
func newFake(t *testing.T) (*fakeGitHub, Config) {
t.Helper()
fake := &fakeGitHub{}
fake.token = func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Error(err)
}
if r.Method != http.MethodPost || r.Header.Get("Accept") != "application/json" {
t.Errorf("换取令牌的请求 = %s,Accept: %q", r.Method, r.Header.Get("Accept"))
}
want := url.Values{"client_id": {"client-1"}, "client_secret": {"secret-1"}, "code": {r.PostForm.Get("code")}, "code_verifier": {verifier}, "redirect_uri": {"https://alisten.example/api/auth/github/callback"}}
if r.PostForm.Encode() != want.Encode() {
t.Errorf("换取令牌的参数 = %s,期望 %s", r.PostForm.Encode(), want.Encode())
}
w.Header().Set("Content-Type", "application/json")
if r.PostForm.Get("code") == "good" {
_, _ = w.Write([]byte(`{"access_token":"token-1","token_type":"bearer","scope":""}`))
return
}
_, _ = w.Write([]byte(`{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired.","error_uri":"/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code"}`))
}
fake.user = func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer token-1" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"Bad credentials"}`))
return
}
if r.Header.Get("User-Agent") == "" || r.Header.Get("Accept") != "application/vnd.github+json" {
t.Errorf("用户接口的请求头 User-Agent: %q,Accept: %q", r.Header.Get("User-Agent"), r.Header.Get("Accept"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"octocat","id":42,"name":"The Octocat"}`))
}
mux := http.NewServeMux()
mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { fake.token(w, r) })
mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { fake.user(w, r) })
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return fake, Config{
RedirectURL: "https://alisten.example/api/auth/github/callback",
Endpoint: Endpoint{
AuthorizeURL: server.URL + "/login/oauth/authorize",
TokenURL: server.URL + "/login/oauth/access_token",
UserURL: server.URL + "/user",
},
}
}
func TestAuthorizeURL(t *testing.T) {
o := New(http.DefaultClient, Config{RedirectURL: "https://alisten.example/api/auth/github/callback", Endpoint: GitHubCom})
u, err := url.Parse(o.AuthorizeURL(app, "state 1&2", verifier))
if err != nil {
t.Fatal(err)
}
if got := u.Scheme + "://" + u.Host + u.Path; got != "https://github.com/login/oauth/authorize" {
t.Errorf("授权地址 = %s", got)
}
want := url.Values{
"client_id": {"client-1"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"redirect_uri": {"https://alisten.example/api/auth/github/callback"},
"state": {"state 1&2"},
}
if got := u.Query(); got.Encode() != want.Encode() {
t.Errorf("授权地址的参数 = %s,期望 %s:不申请任何 scope,只读公开资料", got.Encode(), want.Encode())
}
}
func TestUserID(t *testing.T) {
_, cfg := newFake(t)
o := New(http.DefaultClient, cfg)
if id, err := o.UserID(t.Context(), app, "good", verifier); err != nil || id != 42 {
t.Errorf("UserID = %d, %v,期望 42", id, err)
}
}
func TestUserIDFails(t *testing.T) {
for _, tc := range []struct {
name string
code string
token func(w http.ResponseWriter, r *http.Request)
user func(w http.ResponseWriter, r *http.Request)
want string
}{
{name: "授权码错误或过期", code: "expired", want: "bad_verification_code"},
{name: "令牌接口返回非 200", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}, want: "503"},
{name: "令牌接口返回的不是 JSON", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`access_token=token-1&token_type=bearer`))
}, want: "解码"},
{name: "令牌为空", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"access_token":""}`))
}, want: "没有给出令牌"},
{name: "用户接口拒绝令牌", code: "good", user: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}, want: "401"},
{name: "用户 ID 不是正数", code: "good", user: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"login":"ghost"}`))
}, want: "用户 ID"},
} {
t.Run(tc.name, func(t *testing.T) {
fake, cfg := newFake(t)
if tc.token != nil {
fake.token = tc.token
}
if tc.user != nil {
fake.user = tc.user
}
_, err := New(http.DefaultClient, cfg).UserID(t.Context(), app, tc.code, verifier)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("UserID 的错误 = %v,期望包含 %q", err, tc.want)
}
if err != nil && strings.Contains(err.Error(), "secret-1") {
t.Errorf("错误信息中不能有 client secret:%v", err)
}
})
}
}
// 不响应的 GitHub:请求一直挂着,直到请求的 ctx 结束。
type hangingTransport struct{}
func (hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
}
func TestUserIDTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
o := New(&http.Client{Transport: hangingTransport{}}, Config{Endpoint: GitHubCom})
start := time.Now()
if _, err := o.UserID(t.Context(), app, "good", verifier); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("GitHub 不响应时的错误 = %v,期望超过期限", err)
}
if elapsed := time.Since(start); elapsed != 10*time.Second {
t.Errorf("等了 %v 才中止,期望 10 秒", elapsed)
}
})
}
+1 -1
View File
@@ -41,7 +41,7 @@ func (s *Store) ResetRoomAPIKey(ctx context.Context, roomID string, k port.NewAP
return err
}
} else {
m, err := q.CreateMember(ctx, query.CreateMemberParams{TokenHash: k.BotTokenHash, Name: botName})
m, err := q.CreateMember(ctx, botName)
if err != nil {
return err
}
+17 -3
View File
@@ -2,6 +2,7 @@ package postgres
import (
"errors"
"reflect"
"testing"
"time"
@@ -12,7 +13,7 @@ import (
)
func newAPIKey(key string) port.NewAPIKey {
return port.NewAPIKey{TokenHash: tokenHash(key), BotTokenHash: tokenHash("bot " + key)}
return port.NewAPIKey{TokenHash: tokenHash(key)}
}
func resetAPIKey(t *testing.T, store *Store, roomID, key string) {
@@ -58,7 +59,7 @@ func TestResetRoomAPIKey(t *testing.T) {
if _, err := store.TicketHolder(t.Context(), tokenHash("k1")); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("重置之后旧 Key 的错误 = %v,期望 ErrTicketNotFound", err)
}
if holder, err := store.TicketHolder(t.Context(), tokenHash("k2")); err != nil || holder != bot {
if holder, err := store.TicketHolder(t.Context(), tokenHash("k2")); err != nil || !reflect.DeepEqual(holder, bot) {
t.Errorf("重置之后新 Key 的持有者 = %#v, %v,期望 bot 成员不变", holder, err)
}
if n := memberTickets(t, pool, bot.Member.ID); n != 1 {
@@ -84,7 +85,7 @@ func TestSettingPasswordKeepsAPIKey(t *testing.T) {
if err := store.UpdateRoomAccess(t.Context(), id, port.AccessChange{PasswordHash: ptr("h1")}); err != nil {
t.Fatal(err)
}
if holder, err := store.TicketHolder(t.Context(), tokenHash("k1")); err != nil || holder != bot {
if holder, err := store.TicketHolder(t.Context(), tokenHash("k1")); err != nil || !reflect.DeepEqual(holder, bot) {
t.Errorf("设置密码之后 Key 的持有者 = %#v, %v,期望 Key 仍然有效", holder, err)
}
if n := ticketCount(t, pool, id); n != 1 {
@@ -150,3 +151,16 @@ func TestConcurrentFirstResetsCreateOneBot(t *testing.T) {
t.Errorf("有 %d 把 Key、%d 个 bot 成员,期望各一个", n, bots)
}
}
func TestRoomBotHasNoSession(t *testing.T) {
store, pool := newStore(t)
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
resetAPIKey(t, store, id, "key-1")
var sessions int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM member_sessions s JOIN rooms r ON r.bot_member_id = s.member_id WHERE r.id = $1", id).Scan(&sessions); err != nil {
t.Fatal(err)
}
if sessions != 0 {
t.Errorf("bot 成员有 %d 个会话,期望没有:bot 只通过 API Key 使用 HTTP 接口", sessions)
}
}
@@ -0,0 +1,57 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.GitHubOAuthApps = (*Store)(nil)
const githubCredential = "github"
// Client ID 和 Client Secret 存在凭据表的同一行里,一起写入、一起删除。
type githubOAuthApp struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
func (s *Store) GitHubOAuthApp(ctx context.Context) (port.GitHubOAuthApp, error) {
value, err := s.q.Credential(ctx, githubCredential)
if errors.Is(err, pgx.ErrNoRows) {
return port.GitHubOAuthApp{}, nil
}
if err != nil {
return port.GitHubOAuthApp{}, fmt.Errorf("读取 GitHub OAuth App:%w", err)
}
var app githubOAuthApp
// 不包装解码的错误:其中可能带有原值的片段,也就可能带有 Client Secret。
if err := json.Unmarshal([]byte(value), &app); err != nil || app.ClientID == "" || app.ClientSecret == "" {
return port.GitHubOAuthApp{}, errors.New("凭据表中 GitHub OAuth App 的格式不正确")
}
return port.GitHubOAuthApp(app), nil
}
func (s *Store) SetGitHubOAuthApp(ctx context.Context, app port.GitHubOAuthApp) error {
value, err := json.Marshal(githubOAuthApp(app))
if err != nil {
return fmt.Errorf("编码 GitHub OAuth App:%w", err)
}
if err := s.q.SetCredential(ctx, query.SetCredentialParams{Name: githubCredential, Value: string(value)}); err != nil {
return fmt.Errorf("保存 GitHub OAuth App:%w", err)
}
return nil
}
func (s *Store) DeleteGitHubOAuthApp(ctx context.Context) error {
if err := s.q.DeleteCredential(ctx, githubCredential); err != nil {
return fmt.Errorf("清除 GitHub OAuth App:%w", err)
}
return nil
}
@@ -0,0 +1,91 @@
package postgres
import (
"fmt"
"strings"
"sync"
"testing"
"github.com/dihemoe/alisten/internal/app/port"
)
func TestGitHubOAuthApp(t *testing.T) {
store, pool := newStore(t)
ctx := t.Context()
if app, err := store.GitHubOAuthApp(ctx); err != nil || app != (port.GitHubOAuthApp{}) {
t.Fatalf("没有配置时 = %#v, %v,期望零值", app, err)
}
if err := store.SetNeteaseCookie(ctx, "MUSIC_U=1"); err != nil {
t.Fatal(err)
}
for _, want := range []port.GitHubOAuthApp{{ClientID: "client-1", ClientSecret: "secret-1"}, {ClientID: "client-2", ClientSecret: "secret-2"}} {
if err := store.SetGitHubOAuthApp(ctx, want); err != nil {
t.Fatal(err)
}
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != want {
t.Errorf("GitHubOAuthApp = %#v, %v,期望 %#v", got, err, want)
}
}
// 并发地保存和读取:读到的 Client ID 和 Secret 总是同一次保存的。
var wg sync.WaitGroup
for i := range 4 {
wg.Go(func() {
for j := range 20 {
app := port.GitHubOAuthApp{ClientID: fmt.Sprintf("client-%d-%d", i, j), ClientSecret: fmt.Sprintf("secret-%d-%d", i, j)}
if err := store.SetGitHubOAuthApp(ctx, app); err != nil {
t.Error(err)
return
}
}
})
wg.Go(func() {
for range 20 {
got, err := store.GitHubOAuthApp(ctx)
if err != nil {
t.Error(err)
return
}
if strings.TrimPrefix(got.ClientID, "client-") != strings.TrimPrefix(got.ClientSecret, "secret-") {
t.Errorf("读到的 Client ID 和 Secret 不是同一次保存的:%#v", got)
}
}
})
}
wg.Wait()
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
t.Fatal(err)
}
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != (port.GitHubOAuthApp{}) {
t.Errorf("清除之后 = %#v, %v,期望零值", got, err)
}
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
t.Errorf("没有配置时清除 = %v,期望什么也不做", err)
}
var rows int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM credentials").Scan(&rows); err != nil {
t.Fatal(err)
}
if cookie, err := store.NeteaseCookie(ctx); err != nil || cookie != "MUSIC_U=1" || rows != 1 {
t.Errorf("清除之后网易云 Cookie = %q, %v,凭据表有 %d 行,期望只删除 GitHub 的一行", cookie, err, rows)
}
}
// 存储中的值不是保存时的格式时报错,错误信息中不带原值:其中可能有 Client Secret。
func TestGitHubOAuthAppRejectsCorruptValue(t *testing.T) {
store, pool := newStore(t)
for _, value := range []string{`not json secret-1`, `{"clientId":"client-1"}`, `{"clientSecret":"secret-1"}`} {
if _, err := pool.Exec(t.Context(), "INSERT INTO credentials (name, value) VALUES ('github', $1) ON CONFLICT (name) DO UPDATE SET value = excluded.value", value); err != nil {
t.Fatal(err)
}
_, err := store.GitHubOAuthApp(t.Context())
if err == nil {
t.Errorf("存储的值为 %q 时应报错", value)
continue
}
if strings.Contains(err.Error(), "secret-1") {
t.Errorf("错误信息中有原值:%v", err)
}
}
}
@@ -9,6 +9,48 @@ import (
"context"
)
// iteratorForInsertPlaylistItems implements pgx.CopyFromSource.
type iteratorForInsertPlaylistItems struct {
rows []InsertPlaylistItemsParams
skippedFirstNextCall bool
}
func (r *iteratorForInsertPlaylistItems) Next() bool {
if len(r.rows) == 0 {
return false
}
if !r.skippedFirstNextCall {
r.skippedFirstNextCall = true
return true
}
r.rows = r.rows[1:]
return len(r.rows) > 0
}
func (r iteratorForInsertPlaylistItems) Values() ([]interface{}, error) {
return []interface{}{
r.rows[0].PlaylistID,
r.rows[0].Source,
r.rows[0].TrackID,
r.rows[0].Title,
r.rows[0].Artist,
r.rows[0].Album,
r.rows[0].CoverURL,
r.rows[0].CoverKey,
r.rows[0].DurationMs,
r.rows[0].PageURL,
r.rows[0].Position,
}, nil
}
func (r iteratorForInsertPlaylistItems) Err() error {
return nil
}
func (q *Queries) InsertPlaylistItems(ctx context.Context, arg []InsertPlaylistItemsParams) (int64, error) {
return q.db.CopyFrom(ctx, []string{"playlist_items"}, []string{"playlist_id", "source", "track_id", "title", "artist", "album", "cover_url", "cover_key", "duration_ms", "page_url", "position"}, &iteratorForInsertPlaylistItems{rows: arg})
}
// iteratorForInsertPlays implements pgx.CopyFromSource.
type iteratorForInsertPlays struct {
rows []InsertPlaysParams
@@ -0,0 +1,234 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: listens.sql
package query
import (
"context"
"time"
)
const clearListens = `-- name: ClearListens :exec
DELETE FROM listens WHERE member_id = $1
`
func (q *Queries) ClearListens(ctx context.Context, memberID string) error {
_, err := q.db.Exec(ctx, clearListens, memberID)
return err
}
const deleteListen = `-- name: DeleteListen :exec
DELETE FROM listens WHERE member_id = $1 AND source = $2 AND track_id = $3
`
type DeleteListenParams struct {
MemberID string
Source string
TrackID string
}
func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) error {
_, err := q.db.Exec(ctx, deleteListen, arg.MemberID, arg.Source, arg.TrackID)
return err
}
const listenRecording = `-- name: ListenRecording :one
SELECT record_listens FROM members WHERE id = $1
`
func (q *Queries) ListenRecording(ctx context.Context, id string) (bool, error) {
row := q.db.QueryRow(ctx, listenRecording, id)
var record_listens bool
err := row.Scan(&record_listens)
return record_listens, err
}
const lockListeners = `-- name: LockListeners :many
SELECT id FROM members WHERE id = ANY($1::uuid[]) AND record_listens ORDER BY id FOR NO KEY UPDATE
`
// 按 ID 的顺序锁住开启了记录的成员:同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删;
// 加锁的顺序固定,同时写几个成员的事务之间不会死锁。
func (q *Queries) LockListeners(ctx context.Context, ids []string) ([]string, error) {
rows, err := q.db.Query(ctx, lockListeners, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const memberListens = `-- name: MemberListens :many
SELECT source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM listens WHERE member_id = $1
ORDER BY heard_at DESC, source, track_id
LIMIT $2
`
type MemberListensParams struct {
MemberID string
Max int32
}
type MemberListensRow struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
HeardAt time.Time
}
func (q *Queries) MemberListens(ctx context.Context, arg MemberListensParams) ([]MemberListensRow, error) {
rows, err := q.db.Query(ctx, memberListens, arg.MemberID, arg.Max)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MemberListensRow
for rows.Next() {
var i MemberListensRow
if err := rows.Scan(
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.DurationMs,
&i.PageURL,
&i.HeardAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveListens = `-- name: MoveListens :exec
WITH moved AS (
DELETE FROM listens WHERE member_id = $2::uuid
RETURNING source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
)
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT $1::uuid, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM moved
WHERE EXISTS (SELECT FROM members WHERE id = $1::uuid AND record_listens)
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at)
`
type MoveListensParams struct {
ToID string
FromID string
}
// 并入的规则与 UpsertListens 相同,并入的那份算新写入的;to_id 关闭了记录时只删不并。
func (q *Queries) MoveListens(ctx context.Context, arg MoveListensParams) error {
_, err := q.db.Exec(ctx, moveListens, arg.ToID, arg.FromID)
return err
}
const setListenRecording = `-- name: SetListenRecording :execrows
UPDATE members SET record_listens = $1 WHERE id = $2
`
type SetListenRecordingParams struct {
RecordListens bool
ID string
}
func (q *Queries) SetListenRecording(ctx context.Context, arg SetListenRecordingParams) (int64, error) {
result, err := q.db.Exec(ctx, setListenRecording, arg.RecordListens, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const trimListens = `-- name: TrimListens :exec
DELETE FROM listens l
USING (
SELECT member_id, source, track_id,
row_number() OVER (PARTITION BY member_id ORDER BY heard_at DESC, source, track_id) AS n
FROM listens WHERE member_id = ANY($1::uuid[])
) ranked
WHERE l.member_id = ranked.member_id AND l.source = ranked.source AND l.track_id = ranked.track_id AND ranked.n > $2::bigint
`
type TrimListensParams struct {
MemberIds []string
Keep int64
}
// 排序与 MemberListens 相同:留下的正是读取时返回的那些。
func (q *Queries) TrimListens(ctx context.Context, arg TrimListensParams) error {
_, err := q.db.Exec(ctx, trimListens, arg.MemberIds, arg.Keep)
return err
}
const upsertListens = `-- name: UpsertListens :exec
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT m, $1::text, $2::text, $3::text, $4::text, $5::text, $6::text,
$7::text, $8::bigint, $9::text, $10::timestamptz
FROM unnest($11::uuid[]) AS m
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at)
`
type UpsertListensParams struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
HeardAt time.Time
MemberIds []string
}
func (q *Queries) UpsertListens(ctx context.Context, arg UpsertListensParams) error {
_, err := q.db.Exec(ctx, upsertListens,
arg.Source,
arg.TrackID,
arg.Title,
arg.Artist,
arg.Album,
arg.CoverURL,
arg.CoverKey,
arg.DurationMs,
arg.PageURL,
arg.HeardAt,
arg.MemberIds,
)
return err
}
@@ -10,6 +10,23 @@ import (
"time"
)
const countStoredMedia = `-- name: CountStoredMedia :one
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
`
type CountStoredMediaRow struct {
Total int64
TotalBytes int64
}
func (q *Queries) CountStoredMedia(ctx context.Context, pattern string) (CountStoredMediaRow, error) {
row := q.db.QueryRow(ctx, countStoredMedia, pattern)
var i CountStoredMediaRow
err := row.Scan(&i.Total, &i.TotalBytes)
return i, err
}
const deleteStoredMedia = `-- name: DeleteStoredMedia :exec
DELETE FROM tracks WHERE media_key = $1::text
`
@@ -20,24 +37,43 @@ func (q *Queries) DeleteStoredMedia(ctx context.Context, mediaKey string) error
}
const listStoredMedia = `-- name: ListStoredMedia :many
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL
ORDER BY prepared_at DESC, track_id
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
ORDER BY CASE WHEN $2::text = 'stored' THEN prepared_at END DESC,
CASE WHEN $2::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
CASE WHEN $2::text = 'size' THEN media_size END DESC,
track_id COLLATE "C"
LIMIT $4::bigint OFFSET $3::bigint
`
type ListStoredMediaParams struct {
Pattern string
Sort string
Skip int64
PageSize int64
}
type ListStoredMediaRow struct {
MediaKey string
Source string
TrackID string
Title string
Artist *string
MediaSize int64
CoverURL *string
CoverKey *string
PreparedAt time.Time
LastPlayedAt *time.Time
}
func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, error) {
rows, err := q.db.Query(ctx, listStoredMedia)
// 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
func (q *Queries) ListStoredMedia(ctx context.Context, arg ListStoredMediaParams) ([]ListStoredMediaRow, error) {
rows, err := q.db.Query(ctx, listStoredMedia,
arg.Pattern,
arg.Sort,
arg.Skip,
arg.PageSize,
)
if err != nil {
return nil, err
}
@@ -50,7 +86,9 @@ func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, er
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.MediaSize,
&i.CoverURL,
&i.CoverKey,
&i.PreparedAt,
&i.LastPlayedAt,
@@ -66,7 +104,7 @@ func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, er
}
const storedMedia = `-- name: StoredMedia :one
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key = $1::text
`
@@ -75,7 +113,9 @@ type StoredMediaRow struct {
Source string
TrackID string
Title string
Artist *string
MediaSize int64
CoverURL *string
CoverKey *string
PreparedAt time.Time
LastPlayedAt *time.Time
@@ -89,7 +129,9 @@ func (q *Queries) StoredMedia(ctx context.Context, mediaKey string) (StoredMedia
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.MediaSize,
&i.CoverURL,
&i.CoverKey,
&i.PreparedAt,
&i.LastPlayedAt,
@@ -9,31 +9,231 @@ import (
"context"
)
const createMember = `-- name: CreateMember :one
INSERT INTO members (token_hash, name) VALUES ($1, $2)
RETURNING id, token_hash, name, avatar_hash, created_at
const accountByGitHub = `-- name: AccountByGitHub :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE github_id = $1
`
type CreateMemberParams struct {
TokenHash []byte
Name string
}
func (q *Queries) CreateMember(ctx context.Context, arg CreateMemberParams) (Member, error) {
row := q.db.QueryRow(ctx, createMember, arg.TokenHash, arg.Name)
func (q *Queries) AccountByGitHub(ctx context.Context, githubID *int64) (Member, error) {
row := q.db.QueryRow(ctx, accountByGitHub, githubID)
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const accountByID = `-- name: AccountByID :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = $1 AND (username IS NOT NULL OR github_id IS NOT NULL)
`
func (q *Queries) AccountByID(ctx context.Context, id string) (Member, error) {
row := q.db.QueryRow(ctx, accountByID, id)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const accountByUsername = `-- name: AccountByUsername :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE username = $1
`
func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) {
row := q.db.QueryRow(ctx, accountByUsername, username)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const addMemberCredentials = `-- name: AddMemberCredentials :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL AND github_id IS NOT NULL
`
type AddMemberCredentialsParams struct {
Username *string
PasswordHash *string
ID string
}
// 只给只绑定了 GitHub 的账号设置,它的“我喜欢”已经在绑定时创建了。
func (q *Queries) AddMemberCredentials(ctx context.Context, arg AddMemberCredentialsParams) (int64, error) {
result, err := q.db.Exec(ctx, addMemberCredentials, arg.Username, arg.PasswordHash, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const bindGitHub = `-- name: BindGitHub :one
UPDATE members SET github_id = $1
WHERE id = $2 AND github_id IS NULL
RETURNING (username IS NULL)::boolean AS was_guest
`
type BindGitHubParams struct {
GithubID *int64
ID string
}
// 只给还没有绑定 GitHub 的成员绑定,已经绑定过的成员不受影响,没有返回的行。was_guest 表示成员原来是游客:这次绑定就是注册。
func (q *Queries) BindGitHub(ctx context.Context, arg BindGitHubParams) (bool, error) {
row := q.db.QueryRow(ctx, bindGitHub, arg.GithubID, arg.ID)
var was_guest bool
err := row.Scan(&was_guest)
return was_guest, err
}
const createMember = `-- name: CreateMember :one
INSERT INTO members (name) VALUES ($1)
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
`
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
row := q.db.QueryRow(ctx, createMember, name)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const createSession = `-- name: CreateSession :exec
INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)
`
type CreateSessionParams struct {
TokenHash []byte
MemberID string
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error {
_, err := q.db.Exec(ctx, createSession, arg.TokenHash, arg.MemberID)
return err
}
const deleteMemberSessions = `-- name: DeleteMemberSessions :many
DELETE FROM member_sessions WHERE member_id = $1 AND token_hash IS DISTINCT FROM $2::bytea
RETURNING token_hash
`
type DeleteMemberSessionsParams struct {
MemberID string
Keep []byte
}
// keep 为 NULL 时删除这个成员的全部会话。
func (q *Queries) DeleteMemberSessions(ctx context.Context, arg DeleteMemberSessionsParams) ([][]byte, error) {
rows, err := q.db.Query(ctx, deleteMemberSessions, arg.MemberID, arg.Keep)
if err != nil {
return nil, err
}
defer rows.Close()
var items [][]byte
for rows.Next() {
var token_hash []byte
if err := rows.Scan(&token_hash); err != nil {
return nil, err
}
items = append(items, token_hash)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteSession = `-- name: DeleteSession :execrows
DELETE FROM member_sessions WHERE token_hash = $1
`
func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) (int64, error) {
result, err := q.db.Exec(ctx, deleteSession, tokenHash)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const lockAccountPassword = `-- name: LockAccountPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 AND username IS NOT NULL FOR NO KEY UPDATE
`
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
func (q *Queries) LockAccountPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockAccountPassword, id)
var password_hash string
err := row.Scan(&password_hash)
return password_hash, err
}
const lockMemberPassword = `-- name: LockMemberPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 FOR NO KEY UPDATE
`
// 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的几次登录依次并入游客的历史,否则两边会按相反的顺序抢同一批历史行而死锁。
func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockMemberPassword, id)
var password_hash string
err := row.Scan(&password_hash)
return password_hash, err
}
const memberAccountProfile = `-- name: MemberAccountProfile :one
SELECT username, email, github_id FROM members WHERE id = $1
`
type MemberAccountProfileRow struct {
Username *string
Email *string
GithubID *int64
}
func (q *Queries) MemberAccountProfile(ctx context.Context, id string) (MemberAccountProfileRow, error) {
row := q.db.QueryRow(ctx, memberAccountProfile, id)
var i MemberAccountProfileRow
err := row.Scan(&i.Username, &i.Email, &i.GithubID)
return i, err
}
const memberByToken = `-- name: MemberByToken :one
SELECT id, token_hash, name, avatar_hash, created_at FROM members WHERE token_hash = $1
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
`
func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) {
@@ -41,38 +241,130 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const memberHasSession = `-- name: MemberHasSession :one
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = $1 AND member_id = $2)
`
type MemberHasSessionParams struct {
TokenHash []byte
MemberID string
}
func (q *Queries) MemberHasSession(ctx context.Context, arg MemberHasSessionParams) (bool, error) {
row := q.db.QueryRow(ctx, memberHasSession, arg.TokenHash, arg.MemberID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const registerMember = `-- name: RegisterMember :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL AND github_id IS NULL
`
type RegisterMemberParams struct {
Username *string
PasswordHash *string
ID string
}
// 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
func (q *Queries) RegisterMember(ctx context.Context, arg RegisterMemberParams) (int64, error) {
result, err := q.db.Exec(ctx, registerMember, arg.Username, arg.PasswordHash, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const setMemberPassword = `-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = $1 WHERE id = $2 AND username IS NOT NULL
`
type SetMemberPasswordParams struct {
PasswordHash *string
ID string
}
func (q *Queries) SetMemberPassword(ctx context.Context, arg SetMemberPasswordParams) (int64, error) {
result, err := q.db.Exec(ctx, setMemberPassword, arg.PasswordHash, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const takeSession = `-- name: TakeSession :one
DELETE FROM member_sessions WHERE token_hash = $1 RETURNING member_id
`
func (q *Queries) TakeSession(ctx context.Context, tokenHash []byte) (string, error) {
row := q.db.QueryRow(ctx, takeSession, tokenHash)
var member_id string
err := row.Scan(&member_id)
return member_id, err
}
const unbindGitHub = `-- name: UnbindGitHub :execrows
UPDATE members SET github_id = NULL WHERE id = $1 AND password_hash IS NOT NULL
`
// 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
func (q *Queries) UnbindGitHub(ctx context.Context, id string) (int64, error) {
result, err := q.db.Exec(ctx, unbindGitHub, id)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateMember = `-- name: UpdateMember :one
UPDATE members
SET name = coalesce($1, name),
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END
WHERE id = $3
RETURNING id, token_hash, name, avatar_hash, created_at
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END,
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
WHERE id = $4
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
`
type UpdateMemberParams struct {
Name *string
AvatarHash *string
Email *string
ID string
}
// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。
// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Member, error) {
row := q.db.QueryRow(ctx, updateMember, arg.Name, arg.AvatarHash, arg.ID)
row := q.db.QueryRow(ctx, updateMember,
arg.Name,
arg.AvatarHash,
arg.Email,
arg.ID,
)
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -9,11 +9,30 @@ import (
)
type Member struct {
ID string
TokenHash []byte
Name string
AvatarHash *string
CreatedAt time.Time
ID string
Name string
AvatarHash *string
CreatedAt time.Time
Username *string
PasswordHash *string
Email *string
RecordListens bool
GithubID *int64
}
type PlaylistItem struct {
PlaylistID string
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
Position int64
AddedAt time.Time
}
type Room struct {
@@ -0,0 +1,601 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: playlists.sql
package query
import (
"context"
"time"
)
const copyPlaylistItems = `-- name: CopyPlaylistItems :execrows
WITH wanted AS (
SELECT s.source, k.track_id, min(s.ord) AS ord
FROM unnest($3::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest($4::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
GROUP BY s.source, k.track_id
), copied AS (
SELECT i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY w.ord DESC) AS rank
FROM wanted w
JOIN playlist_items i ON i.playlist_id = $5 AND i.source = w.source AND i.track_id = w.track_id
WHERE NOT EXISTS (
SELECT FROM playlist_items d WHERE d.playlist_id = $1 AND d.source = w.source AND d.track_id = w.track_id
)
)
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT $1, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, $2::bigint + rank
FROM copied
`
type CopyPlaylistItemsParams struct {
ToID string
ItemSeq int64
Sources []string
TrackIds []string
FromID string
}
// 按请求的先后复制:第一首的排序值最大。请求里重复的歌只取第一次出现的位置。
func (q *Queries) CopyPlaylistItems(ctx context.Context, arg CopyPlaylistItemsParams) (int64, error) {
result, err := q.db.Exec(ctx, copyPlaylistItems,
arg.ToID,
arg.ItemSeq,
arg.Sources,
arg.TrackIds,
arg.FromID,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const copySharedPlaylistItems = `-- name: CopySharedPlaylistItems :one
WITH shared AS (
SELECT id FROM playlists WHERE share_id = $1::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT $2, i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied
`
type CopySharedPlaylistItemsParams struct {
ShareID string
ToID string
}
type CopySharedPlaylistItemsRow struct {
Found bool
Copied int64
}
// 确认分享还在和复制曲目在同一条语句里,看到的是同一个快照:要么完整复制,要么读不到分享。不锁来源歌单:两个人同时复制
// 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
func (q *Queries) CopySharedPlaylistItems(ctx context.Context, arg CopySharedPlaylistItemsParams) (CopySharedPlaylistItemsRow, error) {
row := q.db.QueryRow(ctx, copySharedPlaylistItems, arg.ShareID, arg.ToID)
var i CopySharedPlaylistItemsRow
err := row.Scan(&i.Found, &i.Copied)
return i, err
}
const countPlaylistItems = `-- name: CountPlaylistItems :one
SELECT count(*) FROM playlist_items WHERE playlist_id = $1
`
func (q *Queries) CountPlaylistItems(ctx context.Context, playlistID string) (int64, error) {
row := q.db.QueryRow(ctx, countPlaylistItems, playlistID)
var count int64
err := row.Scan(&count)
return count, err
}
const countPlaylists = `-- name: CountPlaylists :one
SELECT count(*) FROM playlists WHERE owner_id = $1
`
func (q *Queries) CountPlaylists(ctx context.Context, ownerID string) (int64, error) {
row := q.db.QueryRow(ctx, countPlaylists, ownerID)
var count int64
err := row.Scan(&count)
return count, err
}
const createFavorites = `-- name: CreateFavorites :exec
INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'favorites', '我喜欢')
`
func (q *Queries) CreateFavorites(ctx context.Context, ownerID string) error {
_, err := q.db.Exec(ctx, createFavorites, ownerID)
return err
}
const createPlaylist = `-- name: CreatePlaylist :one
INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'custom', $2)
RETURNING id, created_at, updated_at
`
type CreatePlaylistParams struct {
OwnerID string
Name string
}
type CreatePlaylistRow struct {
ID string
CreatedAt time.Time
UpdatedAt time.Time
}
func (q *Queries) CreatePlaylist(ctx context.Context, arg CreatePlaylistParams) (CreatePlaylistRow, error) {
row := q.db.QueryRow(ctx, createPlaylist, arg.OwnerID, arg.Name)
var i CreatePlaylistRow
err := row.Scan(&i.ID, &i.CreatedAt, &i.UpdatedAt)
return i, err
}
const deletePlaylist = `-- name: DeletePlaylist :execrows
DELETE FROM playlists WHERE id = $1 AND owner_id = $2 AND kind = 'custom'
`
type DeletePlaylistParams struct {
ID string
OwnerID string
}
func (q *Queries) DeletePlaylist(ctx context.Context, arg DeletePlaylistParams) (int64, error) {
result, err := q.db.Exec(ctx, deletePlaylist, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deletePlaylistItems = `-- name: DeletePlaylistItems :exec
WITH deleted AS (
DELETE FROM playlist_items i
USING unnest($1::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest($2::text[]) WITH ORDINALITY AS t(track_id, ord) USING (ord)
WHERE i.playlist_id = $3 AND i.source = s.source AND i.track_id = t.track_id
RETURNING i.playlist_id
)
UPDATE playlists SET updated_at = now() WHERE id IN (SELECT playlist_id FROM deleted)
`
type DeletePlaylistItemsParams struct {
Sources []string
TrackIds []string
PlaylistID string
}
func (q *Queries) DeletePlaylistItems(ctx context.Context, arg DeletePlaylistItemsParams) error {
_, err := q.db.Exec(ctx, deletePlaylistItems, arg.Sources, arg.TrackIds, arg.PlaylistID)
return err
}
const insertPlaylistItem = `-- name: InsertPlaylistItem :exec
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
type InsertPlaylistItemParams struct {
PlaylistID string
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
Position int64
}
func (q *Queries) InsertPlaylistItem(ctx context.Context, arg InsertPlaylistItemParams) error {
_, err := q.db.Exec(ctx, insertPlaylistItem,
arg.PlaylistID,
arg.Source,
arg.TrackID,
arg.Title,
arg.Artist,
arg.Album,
arg.CoverURL,
arg.CoverKey,
arg.DurationMs,
arg.PageURL,
arg.Position,
)
return err
}
type InsertPlaylistItemsParams struct {
PlaylistID string
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
Position int64
}
const lockFavorites = `-- name: LockFavorites :exec
SELECT id FROM playlists WHERE owner_id = $1 AND kind = 'favorites' FOR UPDATE
`
// 新建歌单前锁住这个账号的“我喜欢”:同一个账号并发新建时依次计数,不会越过上限。
func (q *Queries) LockFavorites(ctx context.Context, ownerID string) error {
_, err := q.db.Exec(ctx, lockFavorites, ownerID)
return err
}
const lockPlaylist = `-- name: LockPlaylist :one
SELECT item_seq FROM playlists WHERE id = $1 AND owner_id = $2 FOR UPDATE
`
type LockPlaylistParams struct {
ID string
OwnerID string
}
// 改动曲目前锁住歌单:并发加入时依次检查上限、分配排序值。删除歌单先锁歌单行再级联删除曲目,其他操作也要先锁歌单再动曲目,否则会互相死锁。
func (q *Queries) LockPlaylist(ctx context.Context, arg LockPlaylistParams) (int64, error) {
row := q.db.QueryRow(ctx, lockPlaylist, arg.ID, arg.OwnerID)
var item_seq int64
err := row.Scan(&item_seq)
return item_seq, err
}
const memberPlaylistCovers = `-- name: MemberPlaylistCovers :many
SELECT DISTINCT ON (i.playlist_id) i.playlist_id, i.cover_url, i.cover_key
FROM playlist_items i
JOIN playlists p ON p.id = i.playlist_id
WHERE p.owner_id = $1 AND (i.cover_url IS NOT NULL OR i.cover_key IS NOT NULL)
ORDER BY i.playlist_id, i.position DESC
`
type MemberPlaylistCoversRow struct {
PlaylistID string
CoverURL *string
CoverKey *string
}
func (q *Queries) MemberPlaylistCovers(ctx context.Context, ownerID string) ([]MemberPlaylistCoversRow, error) {
rows, err := q.db.Query(ctx, memberPlaylistCovers, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MemberPlaylistCoversRow
for rows.Next() {
var i MemberPlaylistCoversRow
if err := rows.Scan(&i.PlaylistID, &i.CoverURL, &i.CoverKey); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const memberPlaylists = `-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = $1
GROUP BY p.id
ORDER BY p.kind = 'favorites' DESC, p.created_at, p.id
`
type MemberPlaylistsRow struct {
ID string
Kind string
Name string
CreatedAt time.Time
UpdatedAt time.Time
ShareID *string
TrackCount int64
}
func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]MemberPlaylistsRow, error) {
rows, err := q.db.Query(ctx, memberPlaylists, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MemberPlaylistsRow
for rows.Next() {
var i MemberPlaylistsRow
if err := rows.Scan(
&i.ID,
&i.Kind,
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
&i.ShareID,
&i.TrackCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const playlistCover = `-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = $1 AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1
`
type PlaylistCoverRow struct {
CoverURL *string
CoverKey *string
}
func (q *Queries) PlaylistCover(ctx context.Context, playlistID string) (PlaylistCoverRow, error) {
row := q.db.QueryRow(ctx, playlistCover, playlistID)
var i PlaylistCoverRow
err := row.Scan(&i.CoverURL, &i.CoverKey)
return i, err
}
const playlistHasItem = `-- name: PlaylistHasItem :one
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = $1 AND source = $2 AND track_id = $3)
`
type PlaylistHasItemParams struct {
PlaylistID string
Source string
TrackID string
}
func (q *Queries) PlaylistHasItem(ctx context.Context, arg PlaylistHasItemParams) (bool, error) {
row := q.db.QueryRow(ctx, playlistHasItem, arg.PlaylistID, arg.Source, arg.TrackID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const playlistItems = `-- name: PlaylistItems :many
SELECT playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position, added_at FROM playlist_items WHERE playlist_id = $1 ORDER BY position DESC
`
func (q *Queries) PlaylistItems(ctx context.Context, playlistID string) ([]PlaylistItem, error) {
rows, err := q.db.Query(ctx, playlistItems, playlistID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []PlaylistItem
for rows.Next() {
var i PlaylistItem
if err := rows.Scan(
&i.PlaylistID,
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.DurationMs,
&i.PageURL,
&i.Position,
&i.AddedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const playlistKind = `-- name: PlaylistKind :one
SELECT kind FROM playlists WHERE id = $1 AND owner_id = $2
`
type PlaylistKindParams struct {
ID string
OwnerID string
}
func (q *Queries) PlaylistKind(ctx context.Context, arg PlaylistKindParams) (string, error) {
row := q.db.QueryRow(ctx, playlistKind, arg.ID, arg.OwnerID)
var kind string
err := row.Scan(&kind)
return kind, err
}
const playlistTracks = `-- name: PlaylistTracks :many
SELECT source, track_id FROM playlist_items WHERE playlist_id = $1 ORDER BY position DESC
`
type PlaylistTracksRow struct {
Source string
TrackID string
}
func (q *Queries) PlaylistTracks(ctx context.Context, playlistID string) ([]PlaylistTracksRow, error) {
rows, err := q.db.Query(ctx, playlistTracks, playlistID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []PlaylistTracksRow
for rows.Next() {
var i PlaylistTracksRow
if err := rows.Scan(&i.Source, &i.TrackID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const renamePlaylist = `-- name: RenamePlaylist :execrows
UPDATE playlists SET name = $1, updated_at = now() WHERE id = $2 AND owner_id = $3 AND kind = 'custom'
`
type RenamePlaylistParams struct {
Name string
ID string
OwnerID string
}
func (q *Queries) RenamePlaylist(ctx context.Context, arg RenamePlaylistParams) (int64, error) {
result, err := q.db.Exec(ctx, renamePlaylist, arg.Name, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const setPlaylistPositions = `-- name: SetPlaylistPositions :exec
UPDATE playlist_items i SET position = t.position
FROM unnest($2::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest($3::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN unnest($4::bigint[]) WITH ORDINALITY AS t(position, ord) USING (ord)
WHERE i.playlist_id = $1 AND i.source = s.source AND i.track_id = k.track_id
`
type SetPlaylistPositionsParams struct {
PlaylistID string
Sources []string
TrackIds []string
Positions []int64
}
func (q *Queries) SetPlaylistPositions(ctx context.Context, arg SetPlaylistPositionsParams) error {
_, err := q.db.Exec(ctx, setPlaylistPositions,
arg.PlaylistID,
arg.Sources,
arg.TrackIds,
arg.Positions,
)
return err
}
const setPlaylistSeq = `-- name: SetPlaylistSeq :exec
UPDATE playlists SET item_seq = $1, updated_at = now() WHERE id = $2
`
type SetPlaylistSeqParams struct {
ItemSeq int64
ID string
}
func (q *Queries) SetPlaylistSeq(ctx context.Context, arg SetPlaylistSeqParams) error {
_, err := q.db.Exec(ctx, setPlaylistSeq, arg.ItemSeq, arg.ID)
return err
}
const setPlaylistShare = `-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = $1 WHERE id = $2 AND owner_id = $3
`
type SetPlaylistShareParams struct {
ShareID *string
ID string
OwnerID string
}
func (q *Queries) SetPlaylistShare(ctx context.Context, arg SetPlaylistShareParams) (int64, error) {
result, err := q.db.Exec(ctx, setPlaylistShare, arg.ShareID, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const sharedPlaylist = `-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = $1::text
`
type SharedPlaylistRow struct {
ID string
Kind string
Name string
OwnerName string
OwnerAvatarHash *string
}
func (q *Queries) SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylistRow, error) {
row := q.db.QueryRow(ctx, sharedPlaylist, shareID)
var i SharedPlaylistRow
err := row.Scan(
&i.ID,
&i.Kind,
&i.Name,
&i.OwnerName,
&i.OwnerAvatarHash,
)
return i, err
}
const touchPlaylist = `-- name: TouchPlaylist :exec
UPDATE playlists SET updated_at = now() WHERE id = $1
`
func (q *Queries) TouchPlaylist(ctx context.Context, id string) error {
_, err := q.db.Exec(ctx, touchPlaylist, id)
return err
}
const trackPlaylists = `-- name: TrackPlaylists :many
SELECT p.id FROM playlists p
JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = $1 AND i.source = $2 AND i.track_id = $3
ORDER BY p.id
`
type TrackPlaylistsParams struct {
OwnerID string
Source string
TrackID string
}
func (q *Queries) TrackPlaylists(ctx context.Context, arg TrackPlaylistsParams) ([]string, error) {
rows, err := q.db.Query(ctx, trackPlaylists, arg.OwnerID, arg.Source, arg.TrackID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -9,23 +9,38 @@ import (
"context"
)
const brandName = `-- name: BrandName :one
SELECT brand_name FROM site
`
func (q *Queries) BrandName(ctx context.Context) (string, error) {
row := q.db.QueryRow(ctx, brandName)
var brand_name string
err := row.Scan(&brand_name)
return brand_name, err
}
const setBrandName = `-- name: SetBrandName :exec
INSERT INTO site (brand_name) VALUES ($1)
ON CONFLICT (id) DO UPDATE SET brand_name = excluded.brand_name
`
func (q *Queries) SetBrandName(ctx context.Context, brandName string) error {
func (q *Queries) SetBrandName(ctx context.Context, brandName *string) error {
_, err := q.db.Exec(ctx, setBrandName, brandName)
return err
}
const setRegistrationOpen = `-- name: SetRegistrationOpen :exec
INSERT INTO site (registration_open) VALUES ($1)
ON CONFLICT (id) DO UPDATE SET registration_open = excluded.registration_open
`
func (q *Queries) SetRegistrationOpen(ctx context.Context, registrationOpen bool) error {
_, err := q.db.Exec(ctx, setRegistrationOpen, registrationOpen)
return err
}
const siteSettings = `-- name: SiteSettings :one
SELECT brand_name, registration_open FROM site
`
type SiteSettingsRow struct {
BrandName *string
RegistrationOpen bool
}
func (q *Queries) SiteSettings(ctx context.Context) (SiteSettingsRow, error) {
row := q.db.QueryRow(ctx, siteSettings)
var i SiteSettingsRow
err := row.Scan(&i.BrandName, &i.RegistrationOpen)
return i, err
}
@@ -10,20 +10,52 @@ import (
)
const createTicket = `-- name: CreateTicket :exec
INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES ($1, $2, $3)
INSERT INTO room_tickets (token_hash, room_id, member_id, session_token_hash)
VALUES ($1, $2, $3, $4)
`
type CreateTicketParams struct {
TokenHash []byte
RoomID string
MemberID string
TokenHash []byte
RoomID string
MemberID string
SessionTokenHash []byte
}
// bot 的 API Key 没有会话,session_token_hash 为 NULL。
func (q *Queries) CreateTicket(ctx context.Context, arg CreateTicketParams) error {
_, err := q.db.Exec(ctx, createTicket, arg.TokenHash, arg.RoomID, arg.MemberID)
_, err := q.db.Exec(ctx, createTicket,
arg.TokenHash,
arg.RoomID,
arg.MemberID,
arg.SessionTokenHash,
)
return err
}
const existingTickets = `-- name: ExistingTickets :many
SELECT token_hash FROM room_tickets WHERE token_hash = ANY($1::bytea[])
`
func (q *Queries) ExistingTickets(ctx context.Context, tokenHashes [][]byte) ([][]byte, error) {
rows, err := q.db.Query(ctx, existingTickets, tokenHashes)
if err != nil {
return nil, err
}
defer rows.Close()
var items [][]byte
for rows.Next() {
var token_hash []byte
if err := rows.Scan(&token_hash); err != nil {
return nil, err
}
items = append(items, token_hash)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockRoomPassword = `-- name: LockRoomPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM rooms WHERE id = $1 FOR SHARE
`
@@ -37,7 +69,7 @@ func (q *Queries) LockRoomPassword(ctx context.Context, id string) (string, erro
}
const ticketHolder = `-- name: TicketHolder :one
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot, t.session_token_hash
FROM room_tickets t
JOIN members m ON m.id = t.member_id
JOIN rooms r ON r.id = t.room_id
@@ -45,11 +77,12 @@ WHERE t.token_hash = $1
`
type TicketHolderRow struct {
RoomID string
ID string
Name string
AvatarHash *string
Bot bool
RoomID string
ID string
Name string
AvatarHash *string
Bot bool
SessionTokenHash []byte
}
func (q *Queries) TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolderRow, error) {
@@ -61,6 +94,7 @@ func (q *Queries) TicketHolder(ctx context.Context, tokenHash []byte) (TicketHol
&i.Name,
&i.AvatarHash,
&i.Bot,
&i.SessionTokenHash,
)
return i, err
}
@@ -106,3 +106,57 @@ func (q *Queries) SavePreparedTrack(ctx context.Context, arg SavePreparedTrackPa
)
return err
}
const trackMetas = `-- name: TrackMetas :many
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
FROM unnest($1::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest($2::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id
`
type TrackMetasParams struct {
Sources []string
TrackIds []string
}
type TrackMetasRow struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
PageURL string
DurationMs int64
}
func (q *Queries) TrackMetas(ctx context.Context, arg TrackMetasParams) ([]TrackMetasRow, error) {
rows, err := q.db.Query(ctx, trackMetas, arg.Sources, arg.TrackIds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TrackMetasRow
for rows.Next() {
var i TrackMetasRow
if err := rows.Scan(
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.PageURL,
&i.DurationMs,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+119
View File
@@ -0,0 +1,119 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
_ port.ListenRecorder = (*Store)(nil)
_ port.Listens = (*Store)(nil)
)
func (s *Store) RecordListen(ctx context.Context, memberIDs []string, l port.Listen) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
ids, err := q.LockListeners(ctx, memberIDs)
if err != nil || len(ids) == 0 {
return err
}
m := l.Meta
err = q.UpsertListens(ctx, query.UpsertListensParams{
MemberIds: ids,
Source: string(l.Track.Source),
TrackID: l.Track.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
HeardAt: l.HeardAt,
})
if err != nil {
return err
}
return q.TrimListens(ctx, query.TrimListensParams{MemberIds: ids, Keep: port.ListenLimit})
})
if err != nil {
return fmt.Errorf("给 %d 个成员记录听到的 %s:%s:%w", len(memberIDs), l.Track.Source, l.Track.ID, err)
}
return nil
}
// 在替换会话的事务中调用:账号行已经被互斥地锁住,与同时进行的记录、同时登录这个账号的其他游客依次写入账号的历史。
func moveListens(ctx context.Context, q *query.Queries, fromID, toID string) error {
if err := q.MoveListens(ctx, query.MoveListensParams{FromID: fromID, ToID: toID}); err != nil {
return err
}
return q.TrimListens(ctx, query.TrimListensParams{MemberIds: []string{toID}, Keep: port.ListenLimit})
}
func (s *Store) MemberListens(ctx context.Context, memberID string) ([]port.Listen, error) {
rows, err := s.q.MemberListens(ctx, query.MemberListensParams{MemberID: memberID, Max: port.ListenLimit})
if err != nil {
return nil, fmt.Errorf("读取成员 %s 的播放历史:%w", memberID, err)
}
listens := make([]port.Listen, len(rows))
for i, r := range rows {
listens[i] = port.Listen{
Track: track.Track{Source: track.Source(r.Source), ID: r.TrackID},
Meta: track.Meta{
Title: r.Title,
Artist: orEmpty(r.Artist),
Album: orEmpty(r.Album),
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
PageURL: r.PageURL,
Duration: time.Duration(r.DurationMs) * time.Millisecond,
},
HeardAt: r.HeardAt,
}
}
return listens, nil
}
func (s *Store) DeleteListen(ctx context.Context, memberID string, t track.Track) error {
if err := s.q.DeleteListen(ctx, query.DeleteListenParams{MemberID: memberID, Source: string(t.Source), TrackID: t.ID}); err != nil {
return fmt.Errorf("删除成员 %s 听过的 %s:%s:%w", memberID, t.Source, t.ID, err)
}
return nil
}
func (s *Store) ClearListens(ctx context.Context, memberID string) error {
if err := s.q.ClearListens(ctx, memberID); err != nil {
return fmt.Errorf("清空成员 %s 的播放历史:%w", memberID, err)
}
return nil
}
func (s *Store) ListenRecording(ctx context.Context, memberID string) (bool, error) {
on, err := s.q.ListenRecording(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return false, fmt.Errorf("读取成员 %s 是否记录播放历史:%w", memberID, err)
}
return on, nil
}
func (s *Store) SetListenRecording(ctx context.Context, memberID string, on bool) error {
n, err := s.q.SetListenRecording(ctx, query.SetListenRecordingParams{ID: memberID, RecordListens: on})
if err == nil && n == 0 {
err = port.ErrMemberNotFound
}
if err != nil {
return fmt.Errorf("设置成员 %s 是否记录播放历史:%w", memberID, err)
}
return nil
}
@@ -0,0 +1,346 @@
package postgres
import (
"errors"
"fmt"
"slices"
"sync"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var heard = time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
func sunnyAt(at time.Time) port.Listen {
return port.Listen{
Track: track.Track{Source: track.NetEase, ID: "186016"},
Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269500 * time.Millisecond},
HeardAt: at,
}
}
func clipAt(at time.Time) port.Listen {
return port.Listen{
Track: track.Track{Source: track.Link, ID: "https://example.com/a.mp3"},
Meta: track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: "https://example.com/a.mp3", Duration: time.Minute},
HeardAt: at,
}
}
func songAt(id string, at time.Time) port.Listen {
return port.Listen{Track: track.Track{Source: track.NetEase, ID: id}, Meta: track.Meta{Title: id, PageURL: track.NetEasePageURL(id)}, HeardAt: at}
}
func record(t *testing.T, store *Store, members []string, l port.Listen) {
t.Helper()
if err := store.RecordListen(t.Context(), members, l); err != nil {
t.Fatal(err)
}
}
// 时间统一换成 UTC,便于直接比较。
func listensOf(t *testing.T, store *Store, memberID string) []port.Listen {
t.Helper()
listens, err := store.MemberListens(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
for i := range listens {
listens[i].HeardAt = listens[i].HeardAt.UTC()
}
return listens
}
func heardIDs(listens []port.Listen) []string {
ids := make([]string, len(listens))
for i, l := range listens {
ids[i] = l.Track.ID
}
return ids
}
func TestRecordListenKeepsLatestHearingOfEachTrack(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
record(t, store, []string{alice.ID, bob.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID}, clipAt(heard.Add(time.Minute)))
if got, want := listensOf(t, store, alice.ID), []port.Listen{clipAt(heard.Add(time.Minute)), sunnyAt(heard)}; !slices.Equal(got, want) {
t.Errorf("alice 的历史 = %#v,期望最近听到的在前 %#v", got, want)
}
if got, want := listensOf(t, store, bob.ID), []port.Listen{sunnyAt(heard)}; !slices.Equal(got, want) {
t.Errorf("bob 的历史 = %#v,期望 %#v", got, want)
}
// 同一首歌再次记录:heard_at 取较晚的,元数据用新的。
renamed := sunnyAt(heard.Add(-time.Hour))
renamed.Meta.Title = "晴天(重制)"
record(t, store, []string{alice.ID}, renamed)
want := sunnyAt(heard)
want.Meta.Title = "晴天(重制)"
if got := listensOf(t, store, alice.ID); !slices.Equal(got, []port.Listen{clipAt(heard.Add(time.Minute)), want}) {
t.Errorf("较早的一次记录之后 = %#v,期望保留较晚的时间、换成新的元数据", got)
}
record(t, store, []string{alice.ID}, sunnyAt(heard.Add(time.Hour)))
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"186016", "https://example.com/a.mp3"}) {
t.Errorf("再次听到《晴天》之后的顺序 = %v,期望它排到最前", got)
}
}
func TestRecordListenTruncatesToStoredPrecision(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
l := sunnyAt(heard.Add(1234567 * time.Nanosecond))
l.Meta.Duration = 3*time.Minute + 1234*time.Microsecond
record(t, store, []string{alice.ID}, l)
got := listensOf(t, store, alice.ID)
if len(got) != 1 || got[0].Meta.Duration != 3*time.Minute+time.Millisecond || !got[0].HeardAt.Equal(heard.Add(1234*time.Microsecond)) {
t.Errorf("历史 = %#v,期望时长按毫秒、时间按微秒截断", got)
}
}
func TestRecordListenSkipsMembersWhoTurnedRecordingOff(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if on, err := store.ListenRecording(t.Context(), bob.ID); err != nil || !on {
t.Fatalf("新成员的 ListenRecording = %v, %v,期望默认开启", on, err)
}
record(t, store, []string{bob.ID}, sunnyAt(heard))
if err := store.SetListenRecording(t.Context(), bob.ID, false); err != nil {
t.Fatal(err)
}
if on, err := store.ListenRecording(t.Context(), bob.ID); err != nil || on {
t.Errorf("关闭之后 ListenRecording = %v, %v", on, err)
}
record(t, store, []string{alice.ID, bob.ID, "01a15778-1911-7a28-8b2e-e08c2a25f388"}, clipAt(heard.Add(time.Minute)))
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"https://example.com/a.mp3"}) {
t.Errorf("alice 的历史 = %v", got)
}
if got := heardIDs(listensOf(t, store, bob.ID)); !slices.Equal(got, []string{"186016"}) {
t.Errorf("bob 关闭记录之后的历史 = %v,期望只有关闭之前的", got)
}
if err := store.SetListenRecording(t.Context(), bob.ID, true); err != nil {
t.Fatal(err)
}
record(t, store, []string{bob.ID}, clipAt(heard.Add(2*time.Minute)))
if got := heardIDs(listensOf(t, store, bob.ID)); len(got) != 2 {
t.Errorf("重新开启之后的历史 = %v,期望继续记录", got)
}
missing := "01a15778-1911-7a28-8b2e-e08c2a25f388"
if _, err := store.ListenRecording(t.Context(), missing); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员的 ListenRecording 错误 = %v,期望 ErrMemberNotFound", err)
}
if err := store.SetListenRecording(t.Context(), missing, false); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员的 SetListenRecording 错误 = %v,期望 ErrMemberNotFound", err)
}
}
func TestRecordListenKeepsLatest500(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
// 第 i 首在 heard 之后 i 分钟听到,s0 最早。
for _, m := range []string{alice.ID, bob.ID} {
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, 499) AS i`, m, heard); err != nil {
t.Fatal(err)
}
}
record(t, store, []string{alice.ID, bob.ID}, songAt("new", heard.Add(time.Hour*24)))
for _, m := range []string{alice.ID, bob.ID} {
got := heardIDs(listensOf(t, store, m))
if len(got) != port.ListenLimit || got[0] != "new" || got[len(got)-1] != "s1" {
t.Errorf("第 501 首之后有 %d 首,最新 %q、最早 %q,期望 500 首,s0 被删掉", len(got), got[0], got[len(got)-1])
}
}
// 比现有的都早的一首,记下之后它自己就是超出的部分。
record(t, store, []string{alice.ID}, songAt("old", heard.Add(-time.Hour)))
if got := heardIDs(listensOf(t, store, alice.ID)); slices.Contains(got, "old") || len(got) != port.ListenLimit {
t.Errorf("更早的一首记下之后有 %d 首,含它 = %v,期望不留", len(got), slices.Contains(got, "old"))
}
}
// 同时开播的几个房间里有同样的两个成员,各自按不同的顺序给出成员,并发写入都成功,每首歌只留一条。
func TestConcurrentRecordsOfSameMembers(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
var wg sync.WaitGroup
errs := make(chan error, 40)
for i := range 40 {
members := []string{alice.ID, bob.ID}
if i%2 == 1 {
members = []string{bob.ID, alice.ID}
}
wg.Go(func() {
errs <- store.RecordListen(t.Context(), members, songAt(fmt.Sprint(i/2%4), heard.Add(time.Duration(i)*time.Second)))
})
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
for _, m := range []string{alice.ID, bob.ID} {
if got := heardIDs(listensOf(t, store, m)); !slices.Equal(got, []string{"3", "2", "1", "0"}) {
t.Errorf("并发记录之后的历史 = %v,期望每首一条、按最后一次听到的时间排列", got)
}
}
}
func TestDeleteAndClearListens(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
record(t, store, []string{alice.ID, bob.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID, bob.ID}, clipAt(heard.Add(time.Minute)))
if err := store.DeleteListen(t.Context(), alice.ID, sunnyAt(heard).Track); err != nil {
t.Fatal(err)
}
if err := store.DeleteListen(t.Context(), alice.ID, track.Track{Source: track.QQ, ID: "missing"}); err != nil {
t.Errorf("删除不在历史里的歌 = %v,期望忽略", err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"https://example.com/a.mp3"}) {
t.Errorf("删除《晴天》之后 alice 的历史 = %v", got)
}
if err := store.ClearListens(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if got := listensOf(t, store, alice.ID); len(got) != 0 {
t.Errorf("清空之后 alice 的历史 = %v", got)
}
if got := heardIDs(listensOf(t, store, bob.ID)); len(got) != 2 {
t.Errorf("alice 删除和清空之后 bob 的历史 = %v,期望不受影响", got)
}
if _, err := pool.Exec(t.Context(), "DELETE FROM members WHERE id = $1", bob.ID); err != nil {
t.Fatal(err)
}
var left int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM listens WHERE member_id = $1", bob.ID).Scan(&left); err != nil || left != 0 {
t.Errorf("删除成员之后还剩 %d 条历史(%v),期望随成员删除", left, err)
}
}
func TestReplaceSessionMovesGuestListensIntoAccount(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
sunny := sunnyAt(heard.Add(time.Hour))
sunny.Meta.Title = "晴天(游客听到的)"
record(t, store, []string{alice.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID}, songAt("1", heard.Add(time.Minute)))
record(t, store, []string{guest.ID}, sunny)
record(t, store, []string{guest.ID}, clipAt(heard.Add(-time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "hash"); err != nil {
t.Fatal(err)
}
if got, want := listensOf(t, store, alice.ID), []port.Listen{sunny, songAt("1", heard.Add(time.Minute)), clipAt(heard.Add(-time.Hour))}; !slices.Equal(got, want) {
t.Errorf("登录之后账号的历史 = %#v,期望并入游客的历史 %#v", got, want)
}
if got := listensOf(t, store, guest.ID); len(got) != 0 {
t.Errorf("登录之后游客的历史 = %v,期望移走", got)
}
}
func TestReplaceSessionMergeFollowsRecordingRules(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
other := createMember(t, store, "other", "游客-5e6f")
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, 499) AS i`, alice.ID, heard); err != nil {
t.Fatal(err)
}
record(t, store, []string{guest.ID}, songAt("new", heard.Add(24*time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "hash"); err != nil {
t.Fatal(err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); len(got) != port.ListenLimit || got[0] != "new" || slices.Contains(got, "s0") {
t.Errorf("并入之后账号有 %d 首,期望 500 首、最早的 s0 被删掉", len(got))
}
if err := store.SetListenRecording(t.Context(), alice.ID, false); err != nil {
t.Fatal(err)
}
record(t, store, []string{other.ID}, songAt("other", heard.Add(48*time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-3"), "hash"); err != nil {
t.Fatal(err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); slices.Contains(got, "other") {
t.Error("账号关闭记录时不应并入游客的历史")
}
if got := listensOf(t, store, other.ID); len(got) != 0 {
t.Errorf("游客的历史 = %v,账号不并入时也随会话一起删掉", got)
}
}
func TestReplaceSessionKeepsListensWhenRejected(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
record(t, store, []string{guest.ID}, sunnyAt(heard))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "changed"); !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Fatalf("密码哈希不符时 = %v", err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("nobody"), alice.ID, tokenHash("alice-2"), "hash"); !errors.Is(err, port.ErrSessionNotFound) {
t.Fatalf("旧会话不存在时 = %v", err)
}
if got := listensOf(t, store, guest.ID); len(got) != 1 {
t.Errorf("登录被拒绝之后游客的历史 = %v,期望不变", got)
}
if got := listensOf(t, store, alice.ID); len(got) != 0 {
t.Errorf("登录被拒绝之后账号的历史 = %v,期望不变", got)
}
}
// 两台设备上的游客同时登录同一个账号,两边听过同一批歌、写入的先后相反:两次合并依次进行,都成功,账号里每首歌只留一条。
func TestConcurrentReplaceSessionsMergeIntoSameAccount(t *testing.T) {
store, pool := newStore(t)
const songs = 200
for round := range 5 {
alice := registered(t, store, fmt.Sprintf("alice-%d", round))
guests := []string{fmt.Sprintf("guest-%d-a", round), fmt.Sprintf("guest-%d-b", round)}
for i, token := range guests {
guest := createMember(t, store, token, "游客-"+token)
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, $3 - 1) AS i ORDER BY i * $4`, guest.ID, heard, songs, 1-2*i); err != nil {
t.Fatal(err)
}
}
start := make(chan struct{})
errs := make([]error, len(guests))
var wg sync.WaitGroup
for i, token := range guests {
wg.Go(func() {
<-start
errs[i] = store.ReplaceSession(t.Context(), tokenHash(token), alice.ID, tokenHash(token+"-alice"), "hash")
})
}
close(start)
wg.Wait()
if err := errors.Join(errs...); err != nil {
t.Fatalf("第 %d 轮同时登录:%v", round, err)
}
got := heardIDs(listensOf(t, store, alice.ID))
if len(got) != songs || got[0] != fmt.Sprintf("s%d", songs-1) || got[songs-1] != "s0" {
t.Errorf("第 %d 轮并入之后账号有 %d 首,期望 %d 首、每首一条、按听到的时间排列", round, len(got), songs)
}
}
}
+40 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -15,16 +16,45 @@ import (
var _ port.MediaLibrary = (*Store)(nil)
func (s *Store) ListStoredMedia(ctx context.Context) ([]port.StoredMedia, error) {
rows, err := s.q.ListStoredMedia(ctx)
var mediaSorts = map[port.MediaSort]string{port.MediaByStored: "stored", port.MediaByIdle: "idle", port.MediaBySize: "size"}
// 搜索的文字按字面匹配:LIKE 的通配符和转义符都要转义。
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
func (s *Store) ListStoredMedia(ctx context.Context, q port.MediaQuery) (port.MediaPage, error) {
pattern := likeEscaper.Replace(q.Title)
var page port.MediaPage
// 总数和这一页在同一个快照里读,两者才对得上。
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
qtx := s.q.WithTx(tx)
count, err := qtx.CountStoredMedia(ctx, pattern)
if err != nil {
return err
}
page.Total, page.TotalBytes = int(count.Total), count.TotalBytes
// 页码来自客户端,可能大到算偏移量时溢出;超出最后一页时也不必再查。
if int64(q.Page-1) > count.Total/port.MediaPageSize {
return nil
}
rows, err := qtx.ListStoredMedia(ctx, query.ListStoredMediaParams{
Pattern: pattern,
Sort: mediaSorts[q.Sort],
Skip: int64(q.Page-1) * port.MediaPageSize,
PageSize: port.MediaPageSize,
})
if err != nil {
return err
}
page.Items = make([]port.StoredMedia, len(rows))
for i, r := range rows {
page.Items[i] = storedMedia(query.StoredMediaRow(r))
}
return nil
})
if err != nil {
return nil, fmt.Errorf("读取已存储的媒体:%w", err)
return port.MediaPage{}, fmt.Errorf("读取已存储的媒体:%w", err)
}
media := make([]port.StoredMedia, len(rows))
for i, r := range rows {
media[i] = storedMedia(query.StoredMediaRow(r))
}
return media, nil
return page, nil
}
func (s *Store) StoredMedia(ctx context.Context, key string) (port.StoredMedia, bool, error) {
@@ -51,7 +81,9 @@ func storedMedia(r query.StoredMediaRow) port.StoredMedia {
Source: track.Source(r.Source),
Link: r.TrackID,
Title: r.Title,
Artist: orEmpty(r.Artist),
Size: r.MediaSize,
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
StoredAt: r.PreparedAt.UTC(),
LastPlayedAt: utc(r.LastPlayedAt),
+122 -12
View File
@@ -1,7 +1,12 @@
package postgres
import (
"cmp"
"fmt"
"math"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -10,10 +15,11 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key, cover string, size int64) {
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key string, meta track.Meta, size int64) {
t.Helper()
meta.Title, meta.PageURL = "标题 "+key, pageURL
p := port.PreparedTrack{
Meta: track.Meta{Title: "标题 " + key, CoverKey: track.ObjectKey(cover), PageURL: pageURL},
Meta: meta,
Result: track.Prepared{Duration: time.Minute, Gain: 1},
MediaKey: key,
MediaSize: size,
@@ -27,9 +33,9 @@ func TestStoredMedia(t *testing.T) {
store, _ := newStore(t)
a := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
saveStored(t, store, a, a.ID, "aaa", "link/aaa/cover.jpg", 100)
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", "", 200)
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", "link/ccc/cover.jpg", 300)
saveStored(t, store, a, a.ID, "aaa", track.Meta{Artist: "周杰伦", CoverKey: "link/aaa/cover.jpg"}, 100)
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", track.Meta{CoverURL: "https://example.com/b.jpg"}, 200)
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", track.Meta{Artist: "某个 UP 主", CoverKey: "link/ccc/cover.jpg"}, 300)
addTrack(t, store, netease("1"), track.Meta{Title: "晴天"}, track.Prepared{Duration: time.Minute, Gain: 1})
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
@@ -37,22 +43,23 @@ func TestStoredMedia(t *testing.T) {
played := room.Entry{ID: 1, Track: a, By: alice}
saveRoom(t, store, term, snap, room.RecordPlay{Entry: played, StartedAt: t0, EndedAt: t0.Add(time.Minute)})
list, err := store.ListStoredMedia(t.Context())
page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored})
if err != nil {
t.Fatal(err)
}
list := page.Items
if len(list) != 3 || list[0].Key != "ccc" || list[1].Key != "bbb" || list[2].Key != "aaa" {
t.Fatalf("已存储的媒体 = %+v,期望两首链接和一首 B 站的歌,后存入的在前", list)
}
got := list[2]
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Size != 100 || got.CoverKey != "link/aaa/cover.jpg" ||
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Artist != "周杰伦" || got.Size != 100 || got.CoverURL != "" || got.CoverKey != "link/aaa/cover.jpg" ||
got.StoredAt.IsZero() || got.LastPlayedAt == nil || !got.LastPlayedAt.Equal(t0) {
t.Errorf("播放过的链接 = %+v", got)
}
if b := list[1]; b.Source != track.Link || b.CoverKey != "" || b.LastPlayedAt != nil {
t.Errorf("没有封面、没有播放过的链接 = %+v", b)
if b := list[1]; b.Source != track.Link || b.Artist != "" || b.CoverURL != "https://example.com/b.jpg" || b.CoverKey != "" || b.LastPlayedAt != nil {
t.Errorf("没有歌手、封面不在对象存储里、没有播放过的链接 = %+v", b)
}
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.Artist != "某个 UP 主" || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
t.Errorf("B 站的歌 = %+v", c)
}
@@ -74,7 +81,110 @@ func TestStoredMedia(t *testing.T) {
t.Errorf("清除之后 %v 的准备结果 = %v, %v,期望没有", tr, ok, err)
}
}
if list, err := store.ListStoredMedia(t.Context()); err != nil || len(list) != 1 {
t.Errorf("清除之后的列表 = %+v, %v", list, err)
if page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored}); err != nil || len(page.Items) != 1 || page.Total != 1 {
t.Errorf("清除之后的列表 = %+v, %v", page, err)
}
}
func mediaKeys(items []port.StoredMedia) []string {
keys := make([]string, len(items))
for i, m := range items {
keys[i] = m.Key
}
slices.Sort(keys)
return keys
}
// 120 首:存储时间和最近播放时间各不相同,其中 10 首没有播放过;每 3 首大小相同,要靠曲目 ID 分出先后。
// 有 3 首的标题含 50%_off;另外两首只在 % 或 _ 被当作通配符时才会匹配它。
func TestStoredMediaPages(t *testing.T) {
store, pool := newStore(t)
titles := map[int]string{5: "Summer 50%_off", 50: "50%_OFF 特价", 95: "全场50%_Off", 20: "50abc_off", 30: "50%Xoff", 40: `C:\music`}
all := make([]port.StoredMedia, 120)
var totalBytes int64
for i := range all {
// 曲目 ID 的顺序与存入的顺序不同,大小相同时是否按曲目 ID 排才看得出来。
link := fmt.Sprintf("https://example.com/%03d.mp3", i*7%120)
m := port.StoredMedia{
Key: fmt.Sprintf("key%03d", i), Source: track.Link, Link: link, Title: cmp.Or(titles[i], fmt.Sprintf("第 %d 首", i)),
Size: int64(1000 + i*11%40), StoredAt: t0.Add(time.Duration(i*13%120) * time.Minute),
}
if i%12 != 0 {
played := t0.Add(time.Duration(i*17%120) * time.Hour)
m.LastPlayedAt = &played
}
saveStored(t, store, track.Track{Source: track.Link, ID: link}, link, m.Key, track.Meta{}, m.Size)
if _, err := pool.Exec(t.Context(), "UPDATE tracks SET title = $1, prepared_at = $2, last_played_at = $3 WHERE media_key = $4", m.Title, m.StoredAt, m.LastPlayedAt, m.Key); err != nil {
t.Fatal(err)
}
all[i] = m
totalBytes += m.Size
}
list := func(q port.MediaQuery) port.MediaPage {
t.Helper()
page, err := store.ListStoredMedia(t.Context(), q)
if err != nil {
t.Fatal(err)
}
return page
}
// 每一页都报告全部的数量和大小;翻过最后一页是空的。
pages := func(sort port.MediaSort) []port.StoredMedia {
t.Helper()
var items []port.StoredMedia
for i, want := range []int{50, 50, 20, 0} {
page := list(port.MediaQuery{Page: i + 1, Sort: sort})
if len(page.Items) != want || page.Total != 120 || page.TotalBytes != totalBytes {
t.Fatalf("排序 %d 的第 %d 页有 %d 首,共 %d 首 %d 字节,期望 %d 首,共 120 首 %d 字节", sort, i+1, len(page.Items), page.Total, page.TotalBytes, want, totalBytes)
}
items = append(items, page.Items...)
}
if got, want := mediaKeys(items), mediaKeys(all); !slices.Equal(got, want) {
t.Fatalf("排序 %d 依次翻页 = %q,期望不重复也不遗漏", sort, got)
}
return items
}
inOrder := func(name string, items []port.StoredMedia, compare func(a, b port.StoredMedia) int) {
t.Helper()
for i := 1; i < len(items); i++ {
if compare(items[i-1], items[i]) >= 0 {
t.Fatalf("%s:%+v 排在了 %+v 之前", name, items[i-1], items[i])
}
}
}
byLink := func(a, b port.StoredMedia) int { return strings.Compare(a.Link, b.Link) }
inOrder("按存储时间", pages(port.MediaByStored), func(a, b port.StoredMedia) int { return b.StoredAt.Compare(a.StoredAt) })
idle := pages(port.MediaByIdle)
for i, m := range idle {
if (m.LastPlayedAt == nil) != (i < 10) {
t.Fatalf("按最近播放的第 %d 首 = %+v,期望前 10 首是没有播放过的", i+1, m)
}
}
inOrder("没有播放过的", idle[:10], byLink)
inOrder("按最近播放", idle[10:], func(a, b port.StoredMedia) int { return a.LastPlayedAt.Compare(*b.LastPlayedAt) })
inOrder("按大小", pages(port.MediaBySize), func(a, b port.StoredMedia) int { return cmp.Or(cmp.Compare(b.Size, a.Size), byLink(a, b)) })
if page := list(port.MediaQuery{Page: math.MaxInt, Sort: port.MediaBySize}); len(page.Items) != 0 || page.Total != 120 || page.TotalBytes != totalBytes {
t.Errorf("页码极大时 = %d 首,共 %d 首 %d 字节,期望没有,总数照常", len(page.Items), page.Total, page.TotalBytes)
}
for _, tt := range []struct {
title string
want []int
}{
{"50%_off", []int{5, 50, 95}},
{"OFF", []int{5, 20, 30, 50, 95}},
{`:\m`, []int{40}},
} {
var want []port.StoredMedia
var bytes int64
for _, i := range tt.want {
want = append(want, all[i])
bytes += all[i].Size
}
page := list(port.MediaQuery{Page: 1, Sort: port.MediaByStored, Title: tt.title})
if got := mediaKeys(page.Items); !slices.Equal(got, mediaKeys(want)) || page.Total != len(want) || page.TotalBytes != bytes {
t.Errorf("搜索 %q = %q,共 %d 首 %d 字节,期望 %q,共 %d 首 %d 字节", tt.title, got, page.Total, page.TotalBytes, mediaKeys(want), len(want), bytes)
}
}
}
+240 -4
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
@@ -15,11 +16,20 @@ import (
var _ port.Members = (*Store)(nil)
func (s *Store) CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error) {
row, err := s.q.CreateMember(ctx, query.CreateMemberParams{TokenHash: tokenHash, Name: name})
var m member.Member
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
row, err := q.CreateMember(ctx, name)
if err != nil {
return err
}
m = memberOf(row)
return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: tokenHash, MemberID: row.ID})
})
if err != nil {
return member.Member{}, fmt.Errorf("创建成员:%w", err)
}
return memberOf(row), nil
return m, nil
}
func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error) {
@@ -33,8 +43,8 @@ func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Mem
return memberOf(row), nil
}
func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error) {
row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash})
func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error) {
row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash, Email: email})
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
@@ -47,3 +57,229 @@ func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *s
func memberOf(row query.Member) member.Member {
return member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)}
}
func (s *Store) AccountProfile(ctx context.Context, memberID string) (port.AccountProfile, error) {
row, err := s.q.MemberAccountProfile(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.AccountProfile{}, fmt.Errorf("读取成员 %s 的账号资料:%w", memberID, err)
}
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email), GitHub: row.GithubID != nil}, nil
}
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
n, err := q.RegisterMember(ctx, query.RegisterMemberParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
if err != nil {
return usernameError(err)
}
if n == 0 {
return port.ErrAlreadyRegistered
}
return q.CreateFavorites(ctx, memberID)
})
if err != nil {
return fmt.Errorf("注册成员 %s:%w", memberID, err)
}
return nil
}
func (s *Store) AddCredentials(ctx context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
// UPDATE 锁住成员行,之后用新的语句删除会话:同时在用 GitHub 登录的设备提交之后才开始删,它签发的会话也会被删掉。
n, err := q.AddMemberCredentials(ctx, query.AddMemberCredentialsParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
if err != nil {
return usernameError(err)
}
if n == 0 {
return port.ErrAlreadyRegistered
}
deleted, err = q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
return err
})
if err != nil {
return nil, fmt.Errorf("给成员 %s 补设用户名和密码:%w", memberID, err)
}
return deleted, nil
}
func usernameError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_username_key" {
return port.ErrUsernameTaken
}
return err
}
func (s *Store) AccountByUsername(ctx context.Context, username string) (port.Account, error) {
row, err := s.q.AccountByUsername(ctx, &username)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("按用户名查找成员:%w", err)
}
return accountOf(row), nil
}
func (s *Store) AccountByID(ctx context.Context, memberID string) (port.Account, error) {
row, err := s.q.AccountByID(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("读取成员 %s 的账号:%w", memberID, err)
}
return accountOf(row), nil
}
func (s *Store) AccountByGitHub(ctx context.Context, githubID int64) (port.Account, error) {
row, err := s.q.AccountByGitHub(ctx, &githubID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("按 GitHub 账号查找成员:%w", err)
}
return accountOf(row), nil
}
func accountOf(row query.Member) port.Account {
account := port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}
if row.GithubID != nil {
account.GitHubID = *row.GithubID
}
return account
}
func (s *Store) BindGitHub(ctx context.Context, memberID string, githubID int64) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
wasGuest, err := q.BindGitHub(ctx, query.BindGitHubParams{ID: memberID, GithubID: &githubID})
var pgErr *pgconn.PgError
switch {
case errors.Is(err, pgx.ErrNoRows):
return port.ErrGitHubAlreadyBound
case errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_github_id_key":
return port.ErrGitHubTaken
case err != nil:
return err
case !wasGuest:
return nil
}
return q.CreateFavorites(ctx, memberID)
})
if err != nil {
return fmt.Errorf("给成员 %s 绑定 GitHub:%w", memberID, err)
}
return nil
}
func (s *Store) UnbindGitHub(ctx context.Context, memberID string) error {
n, err := s.q.UnbindGitHub(ctx, memberID)
if err == nil && n == 0 {
err = port.ErrNoPassword
}
if err != nil {
return fmt.Errorf("给成员 %s 解绑 GitHub:%w", memberID, err)
}
return nil
}
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
current, err := q.LockMemberPassword(ctx, memberID)
if err != nil {
return err
}
if current != passwordHash {
return port.ErrAccountPasswordChanged
}
previous, err := q.TakeSession(ctx, oldTokenHash)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrSessionNotFound
}
if err != nil {
return err
}
if err := q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: memberID}); err != nil {
return err
}
if previous == memberID {
return nil
}
return moveListens(ctx, q, previous, memberID)
})
if err != nil {
return fmt.Errorf("给成员 %s 更换会话:%w", memberID, err)
}
return nil
}
func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error {
if _, err := s.q.DeleteSession(ctx, tokenHash); err != nil {
return fmt.Errorf("删除会话:%w", err)
}
return nil
}
func (s *Store) SetPassword(ctx context.Context, memberID, passwordHash string) ([][]byte, error) {
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
var err error
deleted, err = setPassword(ctx, s.q.WithTx(tx), memberID, passwordHash, nil)
return err
})
if err != nil {
return nil, fmt.Errorf("修改成员 %s 的密码:%w", memberID, err)
}
return deleted, nil
}
func (s *Store) ChangePassword(ctx context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
// 先锁住成员行,再用新的语句查会话:并发的修改或重置提交之后才开始查,才能看到它删掉的会话。
current, err := q.LockAccountPassword(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrMemberNotFound
}
if err != nil {
return err
}
kept, err := q.MemberHasSession(ctx, query.MemberHasSessionParams{TokenHash: keepTokenHash, MemberID: memberID})
if err != nil {
return err
}
if !kept {
return port.ErrSessionNotFound
}
if current != currentPasswordHash {
return port.ErrAccountPasswordChanged
}
deleted, err = setPassword(ctx, q, memberID, passwordHash, keepTokenHash)
return err
})
if err != nil {
return nil, fmt.Errorf("修改成员 %s 的密码:%w", memberID, err)
}
return deleted, nil
}
func setPassword(ctx context.Context, q *query.Queries, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
n, err := q.SetMemberPassword(ctx, query.SetMemberPasswordParams{ID: memberID, PasswordHash: &passwordHash})
if err != nil {
return nil, err
}
if n == 0 {
return nil, port.ErrMemberNotFound
}
return q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
}
+522 -5
View File
@@ -5,6 +5,9 @@ import (
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
@@ -59,13 +62,13 @@ func TestMemberAvatarIsNullWhenUnset(t *testing.T) {
if hash := avatar(); hash != "NULL" {
t.Errorf("新成员的 avatar_hash = %q,期望 NULL", hash)
}
if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346")); err != nil {
if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil); err != nil {
t.Fatal(err)
}
if hash := avatar(); hash != "0bc83cb571cd1c50ba6f3e8a78ef1346" {
t.Errorf("设置头像之后 avatar_hash = %q", hash)
}
got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""))
got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""), nil)
if err != nil {
t.Fatal(err)
}
@@ -78,14 +81,14 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
store, _ := newStore(t)
m := createMember(t, store, "alice", "游客-1a2b")
got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil)
got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil, nil)
if err != nil {
t.Fatal(err)
}
if want := (member.Member{ID: m.ID, Name: "甲"}); got != want {
t.Errorf("只改昵称 = %#v,期望 %#v", got, want)
}
got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"))
got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil)
if err != nil {
t.Fatal(err)
}
@@ -96,7 +99,521 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
t.Errorf("修改后按 token 查到 %#v,期望 %#v", found, got)
}
if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil); !errors.Is(err, port.ErrMemberNotFound) {
if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil, nil); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("修改不存在的成员,错误 = %v,期望 ErrMemberNotFound", err)
}
}
func TestUpdateMemberEmail(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
guest := createMember(t, store, "guest", "游客-3c4d")
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil {
t.Fatal(err)
}
column := func(id string) string {
t.Helper()
var email string
if err := pool.QueryRow(t.Context(), "SELECT coalesce(email, 'NULL') FROM members WHERE id = $1", id).Scan(&email); err != nil {
t.Fatal(err)
}
return email
}
profile := func() port.AccountProfile {
t.Helper()
p, err := store.AccountProfile(t.Context(), alice.ID)
if err != nil {
t.Fatal(err)
}
return p
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("Alice@Example.COM")); err != nil {
t.Fatal(err)
}
if got := profile(); got != (port.AccountProfile{Username: "alice", Email: "Alice@Example.COM"}) {
t.Errorf("保存邮箱之后 AccountProfile = %#v", got)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.Email != "Alice@Example.COM" {
t.Errorf("AccountByUsername 的邮箱 = %q, %v", account.Email, err)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, ptr("甲"), nil, nil); err != nil {
t.Fatal(err)
}
if got := profile().Email; got != "Alice@Example.COM" {
t.Errorf("邮箱参数为 nil 时应保持原值,得到 %q", got)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("b@example.com")); err != nil {
t.Fatal(err)
}
if got := profile().Email; got != "b@example.com" {
t.Errorf("更新邮箱之后 = %q", got)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("")); err != nil {
t.Fatal(err)
}
if got := column(alice.ID); got != "NULL" {
t.Errorf("清除邮箱之后 email 列 = %q,期望 NULL", got)
}
if _, err := store.UpdateMember(t.Context(), guest.ID, nil, nil, ptr("g@example.com")); err != nil {
t.Fatal(err)
}
if got := column(guest.ID); got != "NULL" {
t.Errorf("游客传入邮箱之后 email 列 = %q,期望 NULL", got)
}
if p, err := store.AccountProfile(t.Context(), guest.ID); err != nil || p != (port.AccountProfile{}) {
t.Errorf("游客的 AccountProfile = %#v, %v", p, err)
}
}
func ownerOf(t *testing.T, store *Store, token string) string {
t.Helper()
m, err := store.MemberByToken(t.Context(), tokenHash(token))
if errors.Is(err, port.ErrMemberNotFound) {
return ""
}
if err != nil {
t.Fatal(err)
}
return m.ID
}
func TestRegisterMember(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if profile, err := store.AccountProfile(t.Context(), alice.ID); err != nil || profile != (port.AccountProfile{}) {
t.Fatalf("游客的 AccountProfile = %#v, %v,期望两个字段都为空", profile, err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil {
t.Fatal(err)
}
if profile, err := store.AccountProfile(t.Context(), alice.ID); err != nil || profile != (port.AccountProfile{Username: "alice"}) {
t.Errorf("注册之后的 AccountProfile = %#v, %v,期望只有用户名 alice", profile, err)
}
account, err := store.AccountByUsername(t.Context(), "alice")
if want := (port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a"}); err != nil || account != want {
t.Errorf("AccountByUsername = %#v, %v,期望 %#v", account, err, want)
}
if err := store.Register(t.Context(), bob.ID, "alice", "hash-b"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名重复时 = %v,期望 ErrUsernameTaken", err)
}
if err := store.Register(t.Context(), alice.ID, "alice2", "hash-c"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("已经注册过的成员再注册 = %v,期望 ErrAlreadyRegistered", err)
}
if _, err := store.AccountByUsername(t.Context(), "nobody"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的用户名 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.AccountProfile(t.Context(), "00000000-0000-7000-8000-000000000000"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员 = %v,期望 ErrMemberNotFound", err)
}
}
func TestMemberSessions(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
createMember(t, store, "guest-1", "游客-3c4d")
createMember(t, store, "guest-2", "游客-5e6f")
for _, s := range []struct{ guest, token string }{{"guest-1", "alice-2"}, {"guest-2", "alice-3"}} {
if err := store.ReplaceSession(t.Context(), tokenHash(s.guest), alice.ID, tokenHash(s.token), ""); err != nil {
t.Fatal(err)
}
if got := ownerOf(t, store, s.guest); got != "" {
t.Errorf("换掉之后 %s 仍属于成员 %s", s.guest, got)
}
if got := ownerOf(t, store, s.token); got != alice.ID {
t.Errorf("%s 属于成员 %q,期望 %s", s.token, got, alice.ID)
}
}
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
}
deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1"))
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice-3") != "" {
t.Error("ChangePassword 应只留下发出请求的会话")
}
if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil {
t.Fatal(err)
}
if got := ownerOf(t, store, "alice-1"); got != "" {
t.Errorf("删除之后 alice-1 仍属于成员 %s", got)
}
}
func TestSetPassword(t *testing.T) {
store, _ := newStore(t)
guest := createMember(t, store, "guest", "游客-1a2b")
if _, err := store.SetPassword(t.Context(), guest.ID, "hash"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("给游客设置密码 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.ChangePassword(t.Context(), guest.ID, "", "hash", tokenHash("guest")); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("游客修改密码 = %v,期望 ErrMemberNotFound", err)
}
alice := createMember(t, store, "alice-1", "游客-3c4d")
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
}
createMember(t, store, "other", "游客-5e6f")
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2"), "old"); err != nil {
t.Fatal(err)
}
if deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1")); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2", deleted, err)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "new" {
t.Errorf("修改之后的密码哈希 = %q, %v,期望 new", account.PasswordHash, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" {
t.Error("修改密码应只留下指定的会话")
}
if deleted, err := store.SetPassword(t.Context(), alice.ID, "newer"); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-1")}) {
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-1", deleted, err)
}
if got := ownerOf(t, store, "alice-1"); got != "" {
t.Errorf("SetPassword 应删除全部会话,alice-1 仍属于 %s", got)
}
}
func TestChangePasswordChecksSessionAndPassword(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
createMember(t, store, "bob", "游客-5e6f")
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, current, keep string
want error
}{
{"发出请求的会话已经不存在", "h1", "gone", port.ErrSessionNotFound},
{"会话属于别的成员", "h1", "bob", port.ErrSessionNotFound},
{"密码哈希已经变了", "h0", "alice-1", port.ErrAccountPasswordChanged},
} {
if _, err := store.ChangePassword(t.Context(), alice.ID, tc.current, "h2", tokenHash(tc.keep)); !errors.Is(err, tc.want) {
t.Errorf("%s时 = %v,期望 %v", tc.name, err, tc.want)
}
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "h1" {
t.Errorf("失败之后的密码哈希 = %q, %v,期望不变的 h1", account.PasswordHash, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != alice.ID {
t.Error("失败时不应删除任何会话")
}
}
// 模拟 SetPassword 或另一次 ChangePassword 的事务,先不提交:改密码哈希,删除 keep 以外的会话,keep 为 nil 时全部删除。
func changePasswordMeanwhile(t *testing.T, store *Store, pool *pgxpool.Pool, memberID string, keep []byte, want error) {
t.Helper()
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "UPDATE members SET password_hash = 'h2' WHERE id = $1", memberID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE member_id = $1 AND token_hash IS DISTINCT FROM $2", memberID, keep); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
_, err := store.ChangePassword(t.Context(), memberID, "h1", "h3", tokenHash("alice-1"))
result <- err
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if err := <-result; !errors.Is(err, want) {
t.Errorf("另一次修改提交之后,ChangePassword = %v,期望 %v", err, want)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "h2" {
t.Errorf("密码哈希 = %q, %v,期望先提交的那次写入的 h2", account.PasswordHash, err)
}
}
func TestChangePasswordWaitsForReset(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
changePasswordMeanwhile(t, store, pool, alice.ID, nil, port.ErrSessionNotFound)
}
func TestChangePasswordWaitsForSameSessionChange(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
changePasswordMeanwhile(t, store, pool, alice.ID, tokenHash("alice-1"), port.ErrAccountPasswordChanged)
if ownerOf(t, store, "alice-1") != alice.ID {
t.Error("同一个会话先改过密码,这个会话应仍然有效")
}
}
func TestReplaceSessionChecksGuestSessionAndPassword(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
if err := store.ReplaceSession(t.Context(), tokenHash("gone"), alice.ID, tokenHash("alice-2"), "h1"); !errors.Is(err, port.ErrSessionNotFound) {
t.Errorf("游客的会话已经删除时 = %v,期望 ErrSessionNotFound", err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h0"); !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Errorf("密码哈希已经变了时 = %v,期望 ErrAccountPasswordChanged", err)
}
if ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "guest") == "" {
t.Error("失败时不应创建新会话,也不应删除游客的会话")
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1"); err != nil {
t.Fatal(err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-3"), "h1"); !errors.Is(err, port.ErrSessionNotFound) {
t.Errorf("同一个游客会话再登录一次 = %v,期望 ErrSessionNotFound", err)
}
if ownerOf(t, store, "alice-3") != "" {
t.Error("同一个游客会话只能换出一个新会话")
}
}
func TestReplaceSessionWaitsForPasswordChange(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
// 模拟 SetPassword 的事务,先不提交。
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "UPDATE members SET password_hash = 'h2' WHERE id = $1", alice.ID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE member_id = $1", alice.ID); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
result <- store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1")
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if err := <-result; !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Errorf("改密码提交之后,登录的错误 = %v,期望 ErrAccountPasswordChanged", err)
}
if got := ownerOf(t, store, "alice-2"); got != "" {
t.Errorf("改密码之后还留下了按旧密码签发的会话,属于 %s", got)
}
}
func favoritesCount(t *testing.T, store *Store, memberID string) int {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
n := 0
for _, l := range lists {
if l.Favorites {
n++
}
}
return n
}
func TestBindGitHub(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{GitHub: true}) || !p.Registered() {
t.Errorf("绑定之后的 AccountProfile = %#v, %v,期望只绑定了 GitHub 的已注册账号", p, err)
}
want := port.Account{Member: alice, GitHubID: 42}
if got, err := store.AccountByGitHub(t.Context(), 42); err != nil || got != want {
t.Errorf("AccountByGitHub = %#v, %v,期望 %#v", got, err, want)
}
if got, err := store.AccountByID(t.Context(), alice.ID); err != nil || got != want {
t.Errorf("AccountByID = %#v, %v,期望 %#v", got, err, want)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("游客绑定 GitHub 就是注册,应创建一个“我喜欢”,实际 %d 个", n)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); !errors.Is(err, port.ErrGitHubTaken) {
t.Errorf("GitHub 账号已经绑定了别人 = %v,期望 ErrGitHubTaken", err)
}
if err := store.BindGitHub(t.Context(), alice.ID, 43); !errors.Is(err, port.ErrGitHubAlreadyBound) {
t.Errorf("已经绑定过的成员再绑定 = %v,期望 ErrGitHubAlreadyBound", err)
}
if p, err := store.AccountProfile(t.Context(), bob.ID); err != nil || p.Registered() || favoritesCount(t, store, bob.ID) != 0 {
t.Errorf("绑定失败之后 bob 的 AccountProfile = %#v, %v,应仍是游客", p, err)
}
if _, err := store.AccountByGitHub(t.Context(), 99); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("没有绑定的 GitHub 账号 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.AccountByID(t.Context(), bob.ID); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("游客的 AccountByID = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("alice@example.com")); err != nil {
t.Fatal(err)
}
var email string
if err := pool.QueryRow(t.Context(), "SELECT coalesce(email, 'NULL') FROM members WHERE id = $1", alice.ID).Scan(&email); err != nil || email != "alice@example.com" {
t.Errorf("只绑定了 GitHub 的账号保存邮箱之后 email 列 = %q, %v", email, err)
}
}
func TestAddCredentialsAfterGitHub(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("只绑定了 GitHub 的账号用 Register 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
guest := createMember(t, store, "guest", "游客-5e6f")
if _, err := store.AddCredentials(t.Context(), guest.ID, "guest", "hash-g", tokenHash("guest")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("游客用 AddCredentials 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
if err := store.Register(t.Context(), guest.ID, "taken", "hash-g"); err != nil {
t.Fatal(err)
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "taken", "hash-a", tokenHash("alice")); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
createMember(t, store, "other", "游客-7a8b")
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2"), ""); err != nil {
t.Fatal(err)
}
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
if err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
t.Fatalf("AddCredentials 返回 %x, %v,期望被删除的 alice-2", deleted, err)
}
if ownerOf(t, store, "alice") != alice.ID || ownerOf(t, store, "alice-2") != "" {
t.Error("AddCredentials 应只留下发出请求的会话")
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "alice2", "hash-c", tokenHash("alice")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("再补设一次 = %v,期望 ErrAlreadyRegistered", err)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("只绑定了 GitHub 的账号补设用户名之后有 %d 个“我喜欢”,期望仍是 1 个", n)
}
want := port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a", GitHubID: 42}
if got, err := store.AccountByUsername(t.Context(), "alice"); err != nil || got != want {
t.Errorf("AccountByUsername = %#v, %v,期望 %#v", got, err, want)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{Username: "alice"}) {
t.Errorf("解绑之后的 AccountProfile = %#v, %v", p, err)
}
if _, err := store.AccountByGitHub(t.Context(), 42); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("解绑之后按 GitHub 查找 = %v,期望 ErrMemberNotFound", err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Errorf("没有绑定时解绑 = %v,期望什么也不做", err)
}
bob := createMember(t, store, "bob", "游客-3c4d")
if err := store.Register(t.Context(), bob.ID, "bob", "hash-b"); err != nil {
t.Fatal(err)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); err != nil {
t.Fatal(err)
}
if n := favoritesCount(t, store, bob.ID); n != 1 {
t.Errorf("已注册的账号绑定 GitHub 之后有 %d 个“我喜欢”,期望仍是 1 个", n)
}
}
func TestOnlyAccountsHavePasswordOrEmail(t *testing.T) {
store, pool := newStore(t)
guest := createMember(t, store, "guest", "游客-1a2b")
for _, c := range []struct{ set, constraint string }{
{"password_hash = 'hash'", "members_password_check"},
{"email = 'guest@example.com'", "members_email_check"},
} {
_, err := pool.Exec(t.Context(), "UPDATE members SET "+c.set+" WHERE id = $1", guest.ID)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23514" || pgErr.ConstraintName != c.constraint {
t.Errorf("给没有用户名的游客设置 %s,错误 = %v,期望违反 %s", c.set, err, c.constraint)
}
}
}
// 补设用户名和密码时,用 GitHub 登录的另一台设备恰好在签发会话:补设要等它提交,再把它签发的会话一并删除。
func TestAddCredentialsWaitsForGitHubLogin(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
// 模拟 ReplaceSession 的事务,先不提交。
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "SELECT 1 FROM members WHERE id = $1 FOR NO KEY UPDATE", alice.ID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE token_hash = $1", tokenHash("guest")); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)", tokenHash("alice-2"), alice.ID); err != nil {
t.Fatal(err)
}
type result struct {
deleted [][]byte
err error
}
done := make(chan result, 1)
go func() {
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
done <- result{deleted, err}
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if r := <-done; r.err != nil || !sameHashes(r.deleted, [][]byte{tokenHash("alice-2")}) {
t.Errorf("登录提交之后,AddCredentials 返回 %x, %v,期望删除登录签发的 alice-2", r.deleted, r.err)
}
if ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice") != alice.ID {
t.Error("补设之后应只留下发出请求的会话")
}
}
@@ -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)
}
}
+449
View File
@@ -0,0 +1,449 @@
package postgres
import (
"context"
"errors"
"fmt"
"slices"
"time"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/track"
)
var _ port.Playlists = (*Store)(nil)
const favoritesKind = "favorites"
func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.PlaylistRecord, error) {
rows, err := s.q.MemberPlaylists(ctx, ownerID)
if err != nil {
return nil, fmt.Errorf("读取成员 %s 的歌单:%w", ownerID, err)
}
coverRows, err := s.q.MemberPlaylistCovers(ctx, ownerID)
if err != nil {
return nil, fmt.Errorf("读取成员 %s 的歌单封面:%w", ownerID, err)
}
covers := make(map[string]*port.Cover, len(coverRows))
for _, c := range coverRows {
covers[c.PlaylistID] = &port.Cover{URL: orEmpty(c.CoverURL), Key: track.ObjectKey(orEmpty(c.CoverKey))}
}
lists := make([]port.PlaylistRecord, len(rows))
for i, r := range rows {
lists[i] = port.PlaylistRecord{
ID: r.ID,
Name: r.Name,
Favorites: r.Kind == favoritesKind,
TrackCount: int(r.TrackCount),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
Cover: covers[r.ID],
ShareID: orEmpty(r.ShareID),
}
}
return lists, nil
}
func (s *Store) CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (port.PlaylistRecord, error) {
var created query.CreatePlaylistRow
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
created, err = createPlaylist(ctx, s.q.WithTx(tx), ownerID, name, limit)
return err
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 新建歌单:%w", ownerID, err)
}
return port.PlaylistRecord{ID: created.ID, Name: name, CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}, nil
}
func (s *Store) ImportPlaylist(ctx context.Context, ownerID, name string, items []port.PlaylistItem, limit int) (port.PlaylistRecord, error) {
var created query.CreatePlaylistRow
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
q := s.q.WithTx(tx)
if created, err = createPlaylist(ctx, q, ownerID, name, limit); err != nil {
return err
}
seq := int64(len(items))
rows := make([]query.InsertPlaylistItemsParams, len(items))
for i, item := range items {
t, m := item.Track, item.Meta
rows[i] = query.InsertPlaylistItemsParams{
PlaylistID: created.ID,
Source: string(t.Source),
TrackID: t.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
Position: seq - int64(i),
}
}
if _, err = q.InsertPlaylistItems(ctx, rows); err != nil {
return err
}
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: seq})
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 导入 %d 首歌到新歌单:%w", ownerID, len(items), err)
}
return port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: len(items), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt, Cover: firstCover(items)}, nil
}
// 要在事务中调用:“我喜欢”的行锁持续到新建之后,同一个账号并发新建时才会依次计数。
func createPlaylist(ctx context.Context, q *query.Queries, ownerID, name string, limit int) (query.CreatePlaylistRow, error) {
if err := q.LockFavorites(ctx, ownerID); err != nil {
return query.CreatePlaylistRow{}, err
}
n, err := q.CountPlaylists(ctx, ownerID)
if err != nil {
return query.CreatePlaylistRow{}, err
}
if n >= int64(limit) {
return query.CreatePlaylistRow{}, port.ErrPlaylistLimit
}
return q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
}
func firstCover(items []port.PlaylistItem) *port.Cover {
for _, item := range items {
if m := item.Meta; m.CoverURL != "" || m.CoverKey != "" {
return &port.Cover{URL: m.CoverURL, Key: m.CoverKey}
}
}
return nil
}
func (s *Store) RenamePlaylist(ctx context.Context, ownerID, id, name string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.RenamePlaylist(ctx, query.RenamePlaylistParams{ID: id, OwnerID: ownerID, Name: name})
if err == nil && n == 0 {
err = s.unchangeable(ctx, ownerID, id)
}
if err != nil {
return fmt.Errorf("歌单 %s 改名:%w", id, err)
}
return nil
}
func (s *Store) DeletePlaylist(ctx context.Context, ownerID, id string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.DeletePlaylist(ctx, query.DeletePlaylistParams{ID: id, OwnerID: ownerID})
if err == nil && n == 0 {
err = s.unchangeable(ctx, ownerID, id)
}
if err != nil {
return fmt.Errorf("删除歌单 %s:%w", id, err)
}
return nil
}
// 改名或删除没有影响任何行:要么是“我喜欢”,要么歌单不属于这个成员。
func (s *Store) unchangeable(ctx context.Context, ownerID, id string) error {
kind, err := s.q.PlaylistKind(ctx, query.PlaylistKindParams{ID: id, OwnerID: ownerID})
switch {
case errors.Is(err, pgx.ErrNoRows):
return port.ErrPlaylistNotFound
case err != nil:
return err
case kind == favoritesKind:
return port.ErrFavoritesFixed
}
return port.ErrPlaylistNotFound
}
func (s *Store) owned(ctx context.Context, ownerID, id string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
_, err := s.q.PlaylistKind(ctx, query.PlaylistKindParams{ID: id, OwnerID: ownerID})
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
}
return err
}
func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.PlaylistItem, error) {
if err := s.owned(ctx, ownerID, id); err != nil {
return nil, fmt.Errorf("读取歌单 %s:%w", id, err)
}
rows, err := s.q.PlaylistItems(ctx, id)
if err != nil {
return nil, fmt.Errorf("读取歌单 %s 的曲目:%w", id, err)
}
return playlistItems(rows), nil
}
func playlistItems(rows []query.PlaylistItem) []port.PlaylistItem {
items := make([]port.PlaylistItem, len(rows))
for i, r := range rows {
items[i] = port.PlaylistItem{
Track: track.Track{Source: track.Source(r.Source), ID: r.TrackID},
Meta: track.Meta{
Title: r.Title,
Artist: orEmpty(r.Artist),
Album: orEmpty(r.Album),
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
PageURL: r.PageURL,
Duration: time.Duration(r.DurationMs) * time.Millisecond,
},
}
}
return items
}
func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
t, m := item.Track, item.Meta
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
seq, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: id, OwnerID: ownerID})
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
}
if err != nil {
return err
}
has, err := q.PlaylistHasItem(ctx, query.PlaylistHasItemParams{PlaylistID: id, Source: string(t.Source), TrackID: t.ID})
if err != nil || has {
return err
}
n, err := q.CountPlaylistItems(ctx, id)
if err != nil {
return err
}
if n >= int64(limit) {
return port.ErrPlaylistLimit
}
seq++
if err := q.InsertPlaylistItem(ctx, query.InsertPlaylistItemParams{
PlaylistID: id,
Source: string(t.Source),
TrackID: t.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
Position: seq,
}); err != nil {
return err
}
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: id, ItemSeq: seq})
})
if err != nil {
return fmt.Errorf("向歌单 %s 加入 %s/%s:%w", id, t.Source, t.ID, err)
}
return nil
}
func (s *Store) RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
if _, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: id, OwnerID: ownerID}); errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
} else if err != nil {
return err
}
sources, ids := splitTracks(tracks)
return q.DeletePlaylistItems(ctx, query.DeletePlaylistItemsParams{PlaylistID: id, Sources: sources, TrackIds: ids})
})
if err != nil {
return fmt.Errorf("从歌单 %s 移除 %d 首歌:%w", id, len(tracks), err)
}
return nil
}
func (s *Store) TrackPlaylists(ctx context.Context, ownerID string, t track.Track) ([]string, error) {
ids, err := s.q.TrackPlaylists(ctx, query.TrackPlaylistsParams{OwnerID: ownerID, Source: string(t.Source), TrackID: t.ID})
if err != nil {
return nil, fmt.Errorf("查询曲目 %s 所在的歌单:%w", t.ID, err)
}
if ids == nil {
ids = []string{}
}
return ids, nil
}
func (s *Store) CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) {
var added int64
err := s.owned(ctx, ownerID, fromID)
if err == nil && !isUUID(toID) {
err = port.ErrPlaylistNotFound
}
if err == nil {
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
seq, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: toID, OwnerID: ownerID})
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
}
if err != nil {
return err
}
sources, ids := splitTracks(tracks)
added, err = q.CopyPlaylistItems(ctx, query.CopyPlaylistItemsParams{FromID: fromID, ToID: toID, ItemSeq: seq, Sources: sources, TrackIds: ids})
if err != nil || added == 0 {
return err
}
n, err := q.CountPlaylistItems(ctx, toID)
if err != nil {
return err
}
if n > int64(limit) {
return port.ErrPlaylistLimit
}
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: toID, ItemSeq: seq + added})
})
}
if err != nil {
return 0, fmt.Errorf("从歌单 %s 向歌单 %s 复制 %d 首歌:%w", fromID, toID, len(tracks), err)
}
return int(added), nil
}
func (s *Store) MovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track, before *track.Track) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
if _, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: id, OwnerID: ownerID}); errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
} else if err != nil {
return err
}
rows, err := q.PlaylistTracks(ctx, id)
if err != nil {
return err
}
order := make([]track.Track, len(rows))
for i, r := range rows {
order[i] = track.Track{Source: track.Source(r.Source), ID: r.TrackID}
}
moved, err := playlist.Move(order, tracks, before)
if err != nil || slices.Equal(moved, order) {
return err
}
// 显示顺序里最靠前的取最大的排序值;歌单的 item_seq 不小于曲目数,所以新加入的歌仍然排在最前。
positions := make([]int64, len(moved))
for i := range moved {
positions[i] = int64(len(moved) - i)
}
sources, ids := splitTracks(moved)
if err := q.SetPlaylistPositions(ctx, query.SetPlaylistPositionsParams{PlaylistID: id, Sources: sources, TrackIds: ids, Positions: positions}); err != nil {
return err
}
return q.TouchPlaylist(ctx, id)
})
if err != nil {
return fmt.Errorf("调整歌单 %s 的顺序:%w", id, err)
}
return nil
}
func (s *Store) SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.SetPlaylistShare(ctx, query.SetPlaylistShareParams{ID: id, OwnerID: ownerID, ShareID: nullable(shareID)})
if err == nil && n == 0 {
err = port.ErrPlaylistNotFound
}
if err != nil {
return fmt.Errorf("设置歌单 %s 的分享:%w", id, err)
}
return nil
}
func (s *Store) SharedPlaylist(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
var shared port.SharedPlaylist
// 歌单和曲目在同一个快照里读:分开读时,删除歌单可能恰好发生在两次读取之间,读到歌单还在、曲目却已经没了。
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
p, err := q.SharedPlaylist(ctx, shareID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrShareNotFound
}
if err != nil {
return err
}
rows, err := q.PlaylistItems(ctx, p.ID)
if err != nil {
return err
}
shared = port.SharedPlaylist{
Name: p.Name,
Favorites: p.Kind == favoritesKind,
OwnerName: p.OwnerName,
OwnerAvatarHash: orEmpty(p.OwnerAvatarHash),
Items: playlistItems(rows),
}
return nil
})
if err != nil {
return port.SharedPlaylist{}, fmt.Errorf("读取分享的歌单:%w", err)
}
return shared, nil
}
func (s *Store) CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
var record port.PlaylistRecord
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
created, err := createPlaylist(ctx, q, ownerID, name, limit)
if err != nil {
return err
}
copied, err := q.CopySharedPlaylistItems(ctx, query.CopySharedPlaylistItemsParams{ShareID: shareID, ToID: created.ID})
if err != nil {
return err
}
if !copied.Found {
return port.ErrShareNotFound
}
if err := q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: copied.Copied}); err != nil {
return err
}
record = port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: int(copied.Copied), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}
cover, err := q.PlaylistCover(ctx, created.ID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
record.Cover = &port.Cover{URL: orEmpty(cover.CoverURL), Key: track.ObjectKey(orEmpty(cover.CoverKey))}
return nil
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 复制分享的歌单:%w", ownerID, err)
}
return record, nil
}
func splitTracks(tracks []track.Track) (sources, ids []string) {
sources, ids = make([]string, len(tracks)), make([]string, len(tracks))
for i, t := range tracks {
sources[i], ids[i] = string(t.Source), t.ID
}
return sources, ids
}
@@ -0,0 +1,847 @@
package postgres
import (
"errors"
"slices"
"strconv"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
playlistorder "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/track"
)
func registered(t *testing.T, store *Store, username string) member.Member {
t.Helper()
m := createMember(t, store, username, "游客-"+username)
if err := store.Register(t.Context(), m.ID, username, "hash"); err != nil {
t.Fatal(err)
}
return m
}
func favoritesOf(t *testing.T, store *Store, ownerID string) string {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), ownerID)
if err != nil || len(lists) == 0 || !lists[0].Favorites {
t.Fatalf("歌单 = %#v, %v,期望第一个是“我喜欢”", lists, err)
}
return lists[0].ID
}
var (
sunnyItem = port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269 * time.Second}}
clipItem = port.PlaylistItem{Track: track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}, Meta: track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: "https://example.com/a.mp3", Duration: time.Minute}}
)
func TestRegisterCreatesFavorites(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 1 || !lists[0].Favorites || lists[0].Name != "我喜欢" || lists[0].TrackCount != 0 {
t.Fatalf("注册之后的歌单 = %#v, %v,期望只有一个空的“我喜欢”", lists, err)
}
guest := createMember(t, store, "guest", "游客-1a2b")
if lists, err := store.MemberPlaylists(t.Context(), guest.ID); err != nil || len(lists) != 0 {
t.Errorf("游客的歌单 = %#v, %v,期望没有", lists, err)
}
}
func TestPlaylistLifecycle(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
favorites := favoritesOf(t, store, alice.ID)
commute, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
if err != nil {
t.Fatal(err)
}
if err := store.RenamePlaylist(t.Context(), alice.ID, commute.ID, "上班路上"); err != nil {
t.Fatal(err)
}
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 2 || lists[1].ID != commute.ID || lists[1].Name != "上班路上" || lists[1].TrackCount != 0 || lists[1].Cover != nil {
t.Errorf("歌单 = %#v, %v,期望“我喜欢”之后是改名后的 %s", lists, err, commute.ID)
}
if err := store.RenamePlaylist(t.Context(), alice.ID, favorites, "别的名字"); !errors.Is(err, port.ErrFavoritesFixed) {
t.Errorf("“我喜欢”改名 = %v,期望 ErrFavoritesFixed", err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, favorites); !errors.Is(err, port.ErrFavoritesFixed) {
t.Errorf("删除“我喜欢” = %v,期望 ErrFavoritesFixed", err)
}
for _, id := range []string{commute.ID, "not-a-uuid", "00000000-0000-7000-8000-000000000000"} {
if err := store.RenamePlaylist(t.Context(), bob.ID, id, "x"); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("别人或不存在的歌单 %s 改名 = %v,期望 ErrPlaylistNotFound", id, err)
}
if err := store.DeletePlaylist(t.Context(), bob.ID, id); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("删除别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
if _, err := store.PlaylistItems(t.Context(), bob.ID, id); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("读取别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
}
if err := store.DeletePlaylist(t.Context(), alice.ID, commute.ID); err != nil {
t.Fatal(err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
t.Errorf("删除之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
}
}
func TestDeletePlaylistRemovesItsItems(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
commute, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
if err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, commute.ID, sunnyItem, 1000); err != nil {
t.Fatal(err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, commute.ID); err != nil {
t.Fatal(err)
}
var count int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items WHERE playlist_id = $1`, commute.ID).Scan(&count); err != nil || count != 0 {
t.Errorf("删除歌单之后的曲目数 = %d, %v,期望 0", count, err)
}
}
func TestAccountHasOneFavorites(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
_, err := pool.Exec(t.Context(), `INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'favorites', '我喜欢')`, alice.ID)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != uniqueViolation {
t.Errorf("第二个“我喜欢” = %v,期望唯一约束错误", err)
}
}
func TestPlaylistLimitCountsFavorites(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
if _, err := store.CreatePlaylist(t.Context(), alice.ID, "一", 2); err != nil {
t.Fatal(err)
}
if _, err := store.CreatePlaylist(t.Context(), alice.ID, "二", 2); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("超过上限时 = %v,期望 ErrPlaylistLimit", err)
}
}
func TestPlaylistItems(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
favorites := favoritesOf(t, store, alice.ID)
for _, item := range []port.PlaylistItem{sunnyItem, clipItem, sunnyItem} {
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 2); err != nil {
t.Fatal(err)
}
}
items, err := store.PlaylistItems(t.Context(), alice.ID, favorites)
if want := []port.PlaylistItem{clipItem, sunnyItem}; err != nil || !slices.Equal(items, want) {
t.Errorf("曲目 = %#v, %v,期望新加入的在前、重复加入不变 %#v", items, err, want)
}
if lists, _ := store.MemberPlaylists(t.Context(), alice.ID); lists[0].TrackCount != 2 {
t.Errorf("曲目数 = %d,期望 2", lists[0].TrackCount)
}
third := port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香", PageURL: "https://music.163.com/#/song?id=185811"}}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, third, 2); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单满了时 = %v,期望 ErrPlaylistLimit", err)
}
if err := store.AddPlaylistItem(t.Context(), bob.ID, favorites, third, 2); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("加入别人的歌单 = %v,期望 ErrPlaylistNotFound", err)
}
for range 2 {
if err := store.RemovePlaylistItems(t.Context(), alice.ID, favorites, []track.Track{sunnyItem.Track}); err != nil {
t.Fatal(err)
}
}
if items, err := store.PlaylistItems(t.Context(), alice.ID, favorites); err != nil || !slices.Equal(items, []port.PlaylistItem{clipItem}) {
t.Errorf("移除之后的曲目 = %#v, %v", items, err)
}
if err := store.RemovePlaylistItems(t.Context(), bob.ID, favorites, []track.Track{clipItem.Track}); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("从别人的歌单移除 = %v,期望 ErrPlaylistNotFound", err)
}
}
func TestConcurrentPlaylistAddsRespectLimit(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
favorites := favoritesOf(t, store, alice.ID)
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
item := port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: strconv.Itoa(i)}, Meta: track.Meta{Title: "歌", PageURL: "https://music.163.com/"}}
_ = store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 5)
})
}
wg.Wait()
if items, err := store.PlaylistItems(t.Context(), alice.ID, favorites); err != nil || len(items) != 5 {
t.Errorf("并发加入之后有 %d 首, %v,期望正好 5 首", len(items), err)
}
var distinct, seq int64
var largest int64
if err := pool.QueryRow(t.Context(), `SELECT count(DISTINCT i.position), max(i.position), max(p.item_seq)
FROM playlist_items i JOIN playlists p ON p.id = i.playlist_id WHERE i.playlist_id = $1`, favorites).Scan(&distinct, &largest, &seq); err != nil {
t.Fatal(err)
}
if distinct != 5 || largest > seq {
t.Errorf("排序值有 %d 种、最大 %d,歌单的 item_seq 是 %d,期望 5 首的排序值两两不同且都不大于 item_seq", distinct, largest, seq)
}
}
func TestConcurrentPlaylistCreatesRespectLimit(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
_, _ = store.CreatePlaylist(t.Context(), alice.ID, "歌单"+strconv.Itoa(i), 5)
})
}
wg.Wait()
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 5 {
t.Errorf("并发新建之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err)
}
}
func TestPlaylistTimestamps(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
created, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
if err != nil || created.CreatedAt.IsZero() || created.UpdatedAt.IsZero() || created.Cover != nil {
t.Fatalf("新建 = %#v, %v,期望带创建和更新时间、没有封面", created, err)
}
read := func() port.PlaylistRecord {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v", lists, err)
}
return lists[1]
}
before := read()
if !before.CreatedAt.Equal(created.CreatedAt) || !before.UpdatedAt.Equal(created.UpdatedAt) {
t.Errorf("读出的时间 %v/%v,期望与新建时一致 %v/%v", before.CreatedAt, before.UpdatedAt, created.CreatedAt, created.UpdatedAt)
}
steps := []struct {
name string
do func() error
changed bool
}{
{"改名", func() error { return store.RenamePlaylist(t.Context(), alice.ID, created.ID, "上班路上") }, true},
{"加入新曲目", func() error { return store.AddPlaylistItem(t.Context(), alice.ID, created.ID, sunnyItem, 10) }, true},
{"重复加入", func() error { return store.AddPlaylistItem(t.Context(), alice.ID, created.ID, sunnyItem, 10) }, false},
{"移除曲目", func() error {
return store.RemovePlaylistItems(t.Context(), alice.ID, created.ID, []track.Track{sunnyItem.Track})
}, true},
{"移除不存在的曲目", func() error {
return store.RemovePlaylistItems(t.Context(), alice.ID, created.ID, []track.Track{sunnyItem.Track})
}, false},
}
for _, step := range steps {
if err := step.do(); err != nil {
t.Fatalf("%s:%v", step.name, err)
}
after := read()
if got := after.UpdatedAt.After(before.UpdatedAt); got != step.changed {
t.Errorf("%s之后更新时间推进 = %v,期望 %v", step.name, got, step.changed)
}
if !after.CreatedAt.Equal(before.CreatedAt) {
t.Errorf("%s之后创建时间变了:%v -> %v", step.name, before.CreatedAt, after.CreatedAt)
}
before = after
}
}
func TestPlaylistCoverIsTheFirstSongWithOne(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
favorites := favoritesOf(t, store, alice.ID)
add := func(source track.Source, id, coverURL string, coverKey track.ObjectKey) {
t.Helper()
item := port.PlaylistItem{
Track: track.Track{Source: source, ID: id},
Meta: track.Meta{Title: id, CoverURL: coverURL, CoverKey: coverKey, PageURL: "https://example.com/" + id},
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 100); err != nil {
t.Fatal(err)
}
}
for i := range 3 {
add(track.NetEase, strconv.Itoa(i), "https://img.example/"+strconv.Itoa(i)+".jpg", "")
}
add(track.Link, "https://example.com/a.mp3", "", "covers/a.jpg")
add(track.NetEase, "no-cover", "", "")
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 1 {
t.Fatalf("歌单 = %#v, %v", lists, err)
}
if got, want := lists[0].Cover, (port.Cover{Key: "covers/a.jpg"}); got == nil || *got != want {
t.Errorf("封面 = %+v,期望跳过没有封面的歌,取排在最前的 %+v", got, want)
}
}
func songItem(id string) port.PlaylistItem {
return port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: id}, Meta: track.Meta{Title: "歌" + id, Artist: "艺人", CoverURL: "https://example.com/" + id + ".jpg", PageURL: "https://music.163.com/#/song?id=" + id, Duration: time.Minute}}
}
func displayIDs(t *testing.T, store *Store, ownerID, id string) []string {
t.Helper()
items, err := store.PlaylistItems(t.Context(), ownerID, id)
if err != nil {
t.Fatal(err)
}
ids := make([]string, len(items))
for i, item := range items {
ids[i] = item.Track.ID
}
return ids
}
// 依次加入 ids,显示顺序与 ids 相反。
func filled(t *testing.T, store *Store, ownerID, name string, ids ...string) string {
t.Helper()
p, err := store.CreatePlaylist(t.Context(), ownerID, name, 50)
if err != nil {
t.Fatal(err)
}
for _, id := range ids {
if err := store.AddPlaylistItem(t.Context(), ownerID, p.ID, songItem(id), 1000); err != nil {
t.Fatal(err)
}
}
return p.ID
}
func updatedAt(t *testing.T, store *Store, ownerID, id string) time.Time {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), ownerID)
if err != nil {
t.Fatal(err)
}
for _, l := range lists {
if l.ID == id {
return l.UpdatedAt
}
}
t.Fatalf("没有歌单 %s", id)
return time.Time{}
}
func trackList(ids ...string) []track.Track {
out := make([]track.Track, len(ids))
for i, id := range ids {
out[i] = track.Track{Source: track.NetEase, ID: id}
}
return out
}
func TestRemovePlaylistItemsIgnoresMissingTracks(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
list := filled(t, store, alice.ID, "通勤", "1", "2", "3", "4")
if err := store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList("2", "9", "4", "2")); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, alice.ID, list); !slices.Equal(got, []string{"3", "1"}) {
t.Errorf("批量移出之后 = %v,期望 [3 1]", got)
}
}
func TestCopyPlaylistItems(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
from := filled(t, store, alice.ID, "来源", "1", "2", "3", "4")
to := filled(t, store, alice.ID, "目标", "3", "9")
before := updatedAt(t, store, alice.ID, to)
n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("4", "x", "3", "1", "4"), 1000)
if err != nil || n != 2 {
t.Fatalf("复制 = %d, %v,期望跳过已有的、不存在的和重复的,复制 2 首", n, err)
}
if got := displayIDs(t, store, alice.ID, to); !slices.Equal(got, []string{"4", "1", "9", "3"}) {
t.Errorf("复制之后 = %v,期望新复制的在最前且保持给定的先后 [4 1 9 3]", got)
}
items, _ := store.PlaylistItems(t.Context(), alice.ID, to)
if want := songItem("4"); items[0] != want {
t.Errorf("复制的曲目 = %#v,期望带着元数据快照 %#v", items[0], want)
}
if !updatedAt(t, store, alice.ID, to).After(before) {
t.Error("复制之后目标歌单的更新时间没有推进")
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, to, songItem("5"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, alice.ID, to); got[0] != "5" {
t.Errorf("复制之后新加入的歌 = %v,期望仍在最前", got)
}
before = updatedAt(t, store, alice.ID, to)
if n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("1", "x"), 1000); err != nil || n != 0 {
t.Errorf("没有可复制的歌 = %d, %v,期望 0", n, err)
}
if !updatedAt(t, store, alice.ID, to).Equal(before) {
t.Error("没有复制任何歌,更新时间却变了")
}
if n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("2"), 5); !errors.Is(err, port.ErrPlaylistLimit) || n != 0 {
t.Errorf("装不下时 = %d, %v,期望 ErrPlaylistLimit", n, err)
}
if n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("2"), 6); err != nil || n != 1 {
t.Errorf("正好装满时 = %d, %v,期望复制 1 首", n, err)
}
full := filled(t, store, alice.ID, "满", "1", "2")
if _, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, full, trackList("3", "4", "1"), 3); !errors.Is(err, port.ErrPlaylistLimit) {
t.Fatalf("超限 = %v,期望 ErrPlaylistLimit", err)
}
if got := displayIDs(t, store, alice.ID, full); !slices.Equal(got, []string{"2", "1"}) {
t.Errorf("整批拒绝之后 = %v,期望一首也没有复制", got)
}
other := filled(t, store, bob.ID, "别人的", "7")
for name, call := range map[string]func() (int, error){
"来源是别人的": func() (int, error) {
return store.CopyPlaylistItems(t.Context(), alice.ID, other, to, trackList("7"), 1000)
},
"目标是别人的": func() (int, error) {
return store.CopyPlaylistItems(t.Context(), alice.ID, from, other, trackList("1"), 1000)
},
"来源不是 UUID": func() (int, error) {
return store.CopyPlaylistItems(t.Context(), alice.ID, "x", to, trackList("1"), 1000)
},
"目标不是 UUID": func() (int, error) {
return store.CopyPlaylistItems(t.Context(), alice.ID, from, "x", trackList("1"), 1000)
},
} {
if n, err := call(); !errors.Is(err, port.ErrPlaylistNotFound) || n != 0 {
t.Errorf("%s = %d, %v,期望 ErrPlaylistNotFound", name, n, err)
}
}
}
func TestConcurrentCrossCopiesDoNotDeadlock(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
a := filled(t, store, alice.ID, "甲", "1", "2")
b := filled(t, store, alice.ID, "乙", "3", "4")
var wg sync.WaitGroup
for range 20 {
wg.Go(func() { _, _ = store.CopyPlaylistItems(t.Context(), alice.ID, a, b, trackList("1", "2"), 1000) })
wg.Go(func() { _, _ = store.CopyPlaylistItems(t.Context(), alice.ID, b, a, trackList("3", "4"), 1000) })
}
wg.Wait()
for _, id := range []string{a, b} {
if got := displayIDs(t, store, alice.ID, id); len(got) != 4 {
t.Errorf("互相复制之后有 %v,期望各 4 首", got)
}
}
}
func TestConcurrentRemovalsAndMovesDoNotDeadlock(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
ids := make([]string, 40)
for i := range ids {
ids[i] = strconv.Itoa(i)
}
list := filled(t, store, alice.ID, "通勤", ids...)
errs := make(chan error, 40)
var wg sync.WaitGroup
for i := range 20 {
wg.Go(func() { errs <- store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList(ids[i])) })
wg.Go(func() { errs <- store.MovePlaylistItems(t.Context(), alice.ID, list, trackList(ids[20+i]), nil) })
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Errorf("并发移出和移动 = %v,期望都成功", err)
}
}
got := displayIDs(t, store, alice.ID, list)
slices.Sort(got)
want := slices.Clone(ids[20:])
slices.Sort(want)
if !slices.Equal(got, want) {
t.Errorf("并发移出和移动之后有 %v,期望只剩没有移出的 %v", got, want)
}
}
func TestConcurrentRemovalsAndDeletesDoNotDeadlock(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
lists := make([]string, 20)
for i := range lists {
lists[i] = filled(t, store, alice.ID, "歌单"+strconv.Itoa(i), "1", "2", "3")
}
removed, deleted := make(chan error, len(lists)), make(chan error, len(lists))
var wg sync.WaitGroup
for _, list := range lists {
wg.Go(func() { removed <- store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList("1", "2", "3")) })
wg.Go(func() { deleted <- store.DeletePlaylist(t.Context(), alice.ID, list) })
}
wg.Wait()
close(removed)
close(deleted)
for err := range removed {
if err != nil && !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("与删除歌单并发移出 = %v,期望成功或 ErrPlaylistNotFound", err)
}
}
for err := range deleted {
if err != nil {
t.Errorf("与移出并发删除歌单 = %v,期望成功", err)
}
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
t.Errorf("删除之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
}
var count int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items`).Scan(&count); err != nil || count != 0 {
t.Errorf("删除歌单之后还有 %d 首曲目, %v,期望 0", count, err)
}
}
func TestTrackPlaylistsOnlyListsOwnPlaylists(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
favorites := favoritesOf(t, store, alice.ID)
commute := filled(t, store, alice.ID, "通勤", "1")
filled(t, store, alice.ID, "空", "2")
bobFavorites := favoritesOf(t, store, bob.ID)
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, songItem("1"), 10); err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), bob.ID, bobFavorites, songItem("1"), 10); err != nil {
t.Fatal(err)
}
ids, err := store.TrackPlaylists(t.Context(), alice.ID, songItem("1").Track)
if want := []string{favorites, commute}; err != nil || !slices.Equal(ids, want) {
t.Errorf("所在的歌单 = %v, %v,期望只有本人的 %v", ids, err, want)
}
ids, err = store.TrackPlaylists(t.Context(), alice.ID, songItem("3").Track)
if err != nil || ids == nil || len(ids) != 0 {
t.Errorf("不在任何歌单里的歌 = %#v, %v,期望空切片", ids, err)
}
}
func TestMovePlaylistItems(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
list := filled(t, store, alice.ID, "通勤", "5", "4", "3", "2", "1")
at := func(id string) *track.Track { return &trackList(id)[0] }
steps := []struct {
name string
moved []track.Track
before *track.Track
want []string
}{
{"移到最前", trackList("4"), at("1"), []string{"4", "1", "2", "3", "5"}},
{"移到最后", trackList("4"), nil, []string{"1", "2", "3", "5", "4"}},
{"移到中间", trackList("1"), at("5"), []string{"2", "3", "1", "5", "4"}},
{"多首保持给定的先后", trackList("4", "2"), at("3"), []string{"4", "2", "3", "1", "5"}},
{"忽略不在歌单里的歌", trackList("x", "5"), at("2"), []string{"4", "5", "2", "3", "1"}},
}
for _, step := range steps {
if err := store.MovePlaylistItems(t.Context(), alice.ID, list, step.moved, step.before); err != nil {
t.Fatalf("%s:%v", step.name, err)
}
if got := displayIDs(t, store, alice.ID, list); !slices.Equal(got, step.want) {
t.Errorf("%s之后 = %v,期望 %v", step.name, got, step.want)
}
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, list, songItem("6"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, alice.ID, list); got[0] != "6" {
t.Errorf("移动之后新加入的歌 = %v,期望仍在最前", got)
}
errs := []struct {
name string
moved []track.Track
before *track.Track
want error
}{
{"before 是被移动的歌", trackList("1", "2"), at("2"), playlistorder.ErrBeforeMoved},
{"before 不在歌单里", trackList("1"), at("x"), playlistorder.ErrBeforeMissing},
}
for _, e := range errs {
if err := store.MovePlaylistItems(t.Context(), alice.ID, list, e.moved, e.before); !errors.Is(err, e.want) {
t.Errorf("%s = %v,期望 %v", e.name, err, e.want)
}
}
if err := store.MovePlaylistItems(t.Context(), bob.ID, list, trackList("1"), nil); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("移动别人的歌单 = %v,期望 ErrPlaylistNotFound", err)
}
if err := store.MovePlaylistItems(t.Context(), alice.ID, "not-a-uuid", trackList("1"), nil); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("移动不存在的歌单 = %v,期望 ErrPlaylistNotFound", err)
}
}
func TestMovePlaylistItemsAdvancesUpdatedAt(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
list := filled(t, store, alice.ID, "通勤", "3", "2", "1")
before := updatedAt(t, store, alice.ID, list)
if err := store.MovePlaylistItems(t.Context(), alice.ID, list, trackList("x"), nil); err != nil {
t.Fatal(err)
}
if !updatedAt(t, store, alice.ID, list).Equal(before) {
t.Error("没有歌被移动,更新时间却变了")
}
if err := store.MovePlaylistItems(t.Context(), alice.ID, list, trackList("1"), nil); err != nil {
t.Fatal(err)
}
if !updatedAt(t, store, alice.ID, list).After(before) {
t.Error("移动之后更新时间没有推进")
}
}
func TestImportPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
items := []port.PlaylistItem{songItem("3"), clipItem, songItem("1")}
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "导入的歌单", items, 50)
if err != nil {
t.Fatal(err)
}
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || !slices.Equal(got, items) {
t.Errorf("导入之后的曲目 = %#v, %v,期望与来源同序 %#v", got, err, items)
}
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和导入的歌单", lists, err)
}
read := lists[1]
if imported.ID != read.ID || imported.Name != "导入的歌单" || imported.Favorites || imported.TrackCount != 3 ||
!imported.CreatedAt.Equal(read.CreatedAt) || !imported.UpdatedAt.Equal(read.UpdatedAt) {
t.Errorf("导入返回 %#v,期望与读出的 %#v 一致、共 3 首", imported, read)
}
if want := (port.Cover{URL: songItem("3").Meta.CoverURL}); imported.Cover == nil || *imported.Cover != want || read.Cover == nil || *read.Cover != want {
t.Errorf("封面 = %+v / %+v,期望排在最前的 %+v", imported.Cover, read.Cover, want)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, imported.ID, songItem("9"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, alice.ID, imported.ID); !slices.Equal(got, []string{"9", "3", clipItem.Track.ID, "1"}) {
t.Errorf("导入之后再加入 = %v,期望新加入的排在最前", got)
}
}
func TestImportEmptyPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "空的", nil, 50)
if err != nil || imported.Name != "空的" || imported.TrackCount != 0 || imported.Cover != nil {
t.Fatalf("导入空列表 = %#v, %v,期望建出一个没有曲目的歌单", imported, err)
}
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || len(got) != 0 {
t.Errorf("曲目 = %#v, %v,期望为空", got, err)
}
}
func TestImportPlaylistRollsBackAsAWhole(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
items := []port.PlaylistItem{songItem("1"), songItem("2")}
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "超限", items, 1); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
}
broken := songItem("3")
broken.Meta.Title = "坏\x00标题"
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "写入失败", append(items, broken), 50); err == nil {
t.Error("有一首写不进去时应报错")
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
t.Errorf("失败之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
}
var count int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items`).Scan(&count); err != nil || count != 0 {
t.Errorf("失败之后的曲目数 = %d, %v,期望 0", count, err)
}
}
func TestConcurrentImportsRespectLimit(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
_, _ = store.ImportPlaylist(t.Context(), alice.ID, "导入"+strconv.Itoa(i), []port.PlaylistItem{songItem(strconv.Itoa(i))}, 5)
})
}
wg.Wait()
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 5 {
t.Errorf("并发导入之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err)
}
}
func TestSharePlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
hash := "0bc83cb571cd1c50ba6f3e8a78ef1346"
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, &hash, nil); err != nil {
t.Fatal(err)
}
favorites := favoritesOf(t, store, alice.ID)
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, sunnyItem, 1000); err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, clipItem, 1000); err != nil {
t.Fatal(err)
}
before := updatedAt(t, store, alice.ID, favorites)
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-1"); err != nil {
t.Fatal(err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "share-1" {
t.Errorf("歌单 = %#v, %v,期望带上分享 ID", lists, err)
}
if !updatedAt(t, store, alice.ID, favorites).Equal(before) {
t.Error("分享改变了歌单的更新时间")
}
shared, err := store.SharedPlaylist(t.Context(), "share-1")
want := port.SharedPlaylist{Name: "我喜欢", Favorites: true, OwnerName: alice.Name, OwnerAvatarHash: hash, Items: []port.PlaylistItem{clipItem, sunnyItem}}
if err != nil || shared.Name != want.Name || shared.Favorites != want.Favorites || shared.OwnerName != want.OwnerName ||
shared.OwnerAvatarHash != want.OwnerAvatarHash || !slices.Equal(shared.Items, want.Items) {
t.Errorf("分享的歌单 = %#v, %v,期望 %#v", shared, err, want)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-2"); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("重新生成之后的旧链接 = %v,期望 ErrShareNotFound", err)
}
if shared, err := store.SharedPlaylist(t.Context(), "share-2"); err != nil || len(shared.Items) != 2 {
t.Errorf("新链接 = %#v, %v", shared, err)
}
for _, id := range []string{favorites, "not-a-uuid", "00000000-0000-7000-8000-000000000000"} {
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, "share-3"); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, ""); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("停止分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
}
for range 2 {
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, ""); err != nil {
t.Fatalf("停止分享 = %v,期望重复停止也成功", err)
}
}
if _, err := store.SharedPlaylist(t.Context(), "share-2"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "" {
t.Errorf("停止分享之后的歌单 = %#v, %v,期望没有分享 ID", lists, err)
}
}
func TestShareIDIsUnique(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
if err := store.SetPlaylistShare(t.Context(), alice.ID, favoritesOf(t, store, alice.ID), "share-1"); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, favoritesOf(t, store, bob.ID), "share-1"); err == nil {
t.Error("同一个分享 ID 设给第二个歌单时应报错")
}
if shared, err := store.SharedPlaylist(t.Context(), "share-1"); err != nil || shared.OwnerName != alice.Name {
t.Errorf("分享 = %#v, %v,期望仍是第一个歌单", shared, err)
}
}
func TestDeletedPlaylistIsNoLongerShared(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
list := filled(t, store, alice.ID, "通勤", "1")
if err := store.SetPlaylistShare(t.Context(), alice.ID, list, "share-1"); err != nil {
t.Fatal(err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, list); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("删除歌单之后 = %v,期望 ErrShareNotFound", err)
}
}
func TestCopySharedPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
source := filled(t, store, alice.ID, "通勤", "1", "2", "3")
if err := store.MovePlaylistItems(t.Context(), alice.ID, source, trackList("1"), &trackList("3")[0]); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, source, "share-1"); err != nil {
t.Fatal(err)
}
original := displayIDs(t, store, alice.ID, source)
copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "抄来的", 50)
if err != nil {
t.Fatal(err)
}
if want := (port.Cover{URL: songItem("1").Meta.CoverURL}); copied.Name != "抄来的" || copied.Favorites || copied.TrackCount != 3 ||
copied.ShareID != "" || copied.Cover == nil || *copied.Cover != want {
t.Errorf("复制返回 %#v,期望 3 首、封面是排在最前的 %+v、没有分享", copied, want)
}
lists, err := store.MemberPlaylists(t.Context(), bob.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和复制的歌单", lists, err)
}
if read := lists[1]; read.ID != copied.ID || read.Name != copied.Name || read.TrackCount != 3 || read.ShareID != "" ||
!read.CreatedAt.Equal(copied.CreatedAt) || !read.UpdatedAt.Equal(copied.UpdatedAt) {
t.Errorf("读出的 %#v,期望与复制返回的 %#v 一致", read, copied)
}
items, err := store.PlaylistItems(t.Context(), bob.ID, copied.ID)
sourceItems, _ := store.PlaylistItems(t.Context(), alice.ID, source)
if err != nil || !slices.Equal(items, sourceItems) {
t.Errorf("复制的曲目 = %#v, %v,期望与原歌单相同 %#v", items, err, sourceItems)
}
if err := store.AddPlaylistItem(t.Context(), bob.ID, copied.ID, songItem("9"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, bob.ID, copied.ID); !slices.Equal(got, append([]string{"9"}, original...)) {
t.Errorf("复制之后再加入 = %v,期望新加入的排在最前、其余保持 %v", got, original)
}
if got := displayIDs(t, store, alice.ID, source); !slices.Equal(got, original) {
t.Errorf("原歌单 = %v,期望不变 %v", got, original)
}
empty := filled(t, store, alice.ID, "空的")
if err := store.SetPlaylistShare(t.Context(), alice.ID, empty, "share-2"); err != nil {
t.Fatal(err)
}
if copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-2", "空的", 50); err != nil || copied.TrackCount != 0 || copied.Cover != nil {
t.Errorf("复制空歌单 = %#v, %v,期望建出一个没有曲目的歌单", copied, err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "超限", 3); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-x", "失效", 50); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("读不到的分享 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), bob.ID); err != nil || len(lists) != 3 {
t.Errorf("失败之后有 %d 个歌单, %v,期望没有多建", len(lists), err)
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ func addTicket(t *testing.T, pool *pgxpool.Pool, roomID string) {
t.Helper()
_, err := pool.Exec(t.Context(), `
WITH m AS (
INSERT INTO members (token_hash, name) VALUES (sha256(gen_random_uuid()::text::bytea), '甲')
INSERT INTO members (name) VALUES ('甲')
RETURNING id
)
INSERT INTO room_tickets (token_hash, room_id, member_id) SELECT sha256(gen_random_uuid()::text::bytea), $1, id FROM m`,
+13 -6
View File
@@ -12,20 +12,27 @@ import (
var _ port.Site = (*Store)(nil)
func (s *Store) BrandName(ctx context.Context) (string, error) {
name, err := s.q.BrandName(ctx)
func (s *Store) Settings(ctx context.Context) (port.SiteSettings, error) {
row, err := s.q.SiteSettings(ctx)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
return port.SiteSettings{RegistrationOpen: true}, nil
}
if err != nil {
return "", fmt.Errorf("读取品牌名:%w", err)
return port.SiteSettings{}, fmt.Errorf("读取站点设置:%w", err)
}
return name, nil
return port.SiteSettings{BrandName: orEmpty(row.BrandName), RegistrationOpen: row.RegistrationOpen}, nil
}
func (s *Store) SetBrandName(ctx context.Context, name string) error {
if err := s.q.SetBrandName(ctx, name); err != nil {
if err := s.q.SetBrandName(ctx, &name); err != nil {
return fmt.Errorf("保存品牌名:%w", err)
}
return nil
}
func (s *Store) SetRegistrationOpen(ctx context.Context, open bool) error {
if err := s.q.SetRegistrationOpen(ctx, open); err != nil {
return fmt.Errorf("保存注册开关:%w", err)
}
return nil
}
+23 -9
View File
@@ -1,20 +1,34 @@
package postgres
import "testing"
import (
"testing"
func TestBrandName(t *testing.T) {
"github.com/dihemoe/alisten/internal/app/port"
)
func TestSiteSettings(t *testing.T) {
store, pool := newStore(t)
if name, err := store.BrandName(t.Context()); err != nil || name != "" {
t.Fatalf("没有设置过时 = %q, %v,期望空字符串", name, err)
check := func(want port.SiteSettings, why string) {
t.Helper()
if got, err := store.Settings(t.Context()); err != nil || got != want {
t.Errorf("%s:Settings = %#v, %v,期望 %#v", why, got, err, want)
}
}
for _, want := range []string{"周末听歌会", "Alisten"} {
if err := store.SetBrandName(t.Context(), want); err != nil {
check(port.SiteSettings{RegistrationOpen: true}, "没有设置过时没有品牌名、开放注册")
if err := store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
check(port.SiteSettings{RegistrationOpen: false}, "只关闭注册时品牌名仍未设置")
for _, name := range []string{"周末听歌会", "Alisten"} {
if err := store.SetBrandName(t.Context(), name); err != nil {
t.Fatal(err)
}
if got, err := store.BrandName(t.Context()); err != nil || got != want {
t.Errorf("BrandName = %q, %v,期望 %q", got, err, want)
}
check(port.SiteSettings{BrandName: name, RegistrationOpen: false}, "修改品牌名不影响注册开关")
}
if err := store.SetRegistrationOpen(t.Context(), true); err != nil {
t.Fatal(err)
}
check(port.SiteSettings{BrandName: "Alisten", RegistrationOpen: true}, "重新开放注册不影响品牌名")
var rows int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM site").Scan(&rows); err != nil {
t.Fatal(err)
+29 -6
View File
@@ -6,13 +6,17 @@ import (
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
var _ port.Tickets = (*Store)(nil)
var (
_ port.Tickets = (*Store)(nil)
_ port.ExistingTickets = (*Store)(nil)
)
func (s *Store) CreateTicket(ctx context.Context, t port.NewTicket) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
@@ -27,10 +31,10 @@ func (s *Store) CreateTicket(ctx context.Context, t port.NewTicket) error {
if hash != t.PasswordHash {
return port.ErrPasswordChanged
}
return q.CreateTicket(ctx, query.CreateTicketParams{TokenHash: t.TokenHash, RoomID: t.RoomID, MemberID: t.MemberID})
return q.CreateTicket(ctx, query.CreateTicketParams{TokenHash: t.TokenHash, RoomID: t.RoomID, MemberID: t.MemberID, SessionTokenHash: t.SessionTokenHash})
})
if err != nil {
return fmt.Errorf("签发房间 %s 的票据:%w", t.RoomID, err)
return fmt.Errorf("签发房间 %s 的票据:%w", t.RoomID, sessionError(err))
}
return nil
}
@@ -44,8 +48,27 @@ func (s *Store) TicketHolder(ctx context.Context, tokenHash []byte) (port.Ticket
return port.TicketHolder{}, fmt.Errorf("查找票据:%w", err)
}
return port.TicketHolder{
RoomID: row.RoomID,
Member: member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)},
Bot: row.Bot,
RoomID: row.RoomID,
Member: member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)},
Bot: row.Bot,
SessionTokenHash: row.SessionTokenHash,
}, nil
}
func (s *Store) ExistingTickets(ctx context.Context, tokenHashes [][]byte) ([][]byte, error) {
existing, err := s.q.ExistingTickets(ctx, tokenHashes)
if err != nil {
return nil, fmt.Errorf("核对 %d 张票据:%w", len(tokenHashes), err)
}
return existing, nil
}
const foreignKeyViolation = "23503"
func sessionError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == foreignKeyViolation && pgErr.ConstraintName == "room_tickets_session_token_hash_fkey" {
return port.ErrSessionNotFound
}
return err
}
+110 -3
View File
@@ -2,6 +2,8 @@ package postgres
import (
"errors"
"reflect"
"slices"
"testing"
"time"
@@ -16,15 +18,15 @@ func TestTicketHolder(t *testing.T) {
alice := createMember(t, store, "alice", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID}); err != nil {
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID, SessionTokenHash: tokenHash("alice")}); err != nil {
t.Fatal(err)
}
got, err := store.TicketHolder(t.Context(), tokenHash("t1"))
if want := (port.TicketHolder{RoomID: id, Member: alice}); err != nil || got != want {
if want := (port.TicketHolder{RoomID: id, Member: alice, SessionTokenHash: tokenHash("alice")}); err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("TicketHolder = %#v, %v,期望 %#v", got, err, want)
}
renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil)
renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil, nil)
if err != nil {
t.Fatal(err)
}
@@ -37,6 +39,111 @@ func TestTicketHolder(t *testing.T) {
}
}
func TestExistingTickets(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
for _, ticket := range []string{"t1", "t2"} {
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash(ticket), RoomID: id, MemberID: alice.ID, SessionTokenHash: tokenHash("alice")}); err != nil {
t.Fatal(err)
}
}
resetAPIKey(t, store, id, "key")
got, err := store.ExistingTickets(t.Context(), [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key"), tokenHash("t3"), tokenHash("t1")})
if want := [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key")}; err != nil || !sameHashes(got, want) {
t.Errorf("ExistingTickets = %x, %v,期望 t1、t2 和 API Key 各一次", got, err)
}
if err := store.DeleteSession(t.Context(), tokenHash("alice")); err != nil {
t.Fatal(err)
}
got, err = store.ExistingTickets(t.Context(), [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key")})
if want := [][]byte{tokenHash("key")}; err != nil || !sameHashes(got, want) {
t.Errorf("会话删除之后 ExistingTickets = %x, %v,期望只剩 API Key", got, err)
}
}
func TestCreateTicketRejectsDeletedSession(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
if err := store.DeleteSession(t.Context(), tokenHash("alice")); err != nil {
t.Fatal(err)
}
err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID, SessionTokenHash: tokenHash("alice")})
if !errors.Is(err, port.ErrSessionNotFound) {
t.Errorf("会话已经删除时 CreateTicket = %v,期望 ErrSessionNotFound", err)
}
}
func TestTicketsBelongToSessions(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
for _, guest := range []string{"guest-2", "guest-3", "guest-4"} {
createMember(t, store, guest, "游客")
}
for _, s := range []struct{ guest, token string }{{"guest-2", "alice-2"}, {"guest-3", "alice-3"}} {
if err := store.ReplaceSession(t.Context(), tokenHash(s.guest), alice.ID, tokenHash(s.token), ""); err != nil {
t.Fatal(err)
}
}
for _, session := range []string{"alice-1", "alice-2", "alice-3", "guest-4"} {
memberID := alice.ID
if session == "guest-4" {
memberID = ownerOf(t, store, "guest-4")
}
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("ticket-" + session), RoomID: id, MemberID: memberID, SessionTokenHash: tokenHash(session)}); err != nil {
t.Fatal(err)
}
}
valid := func(session string) bool {
t.Helper()
_, err := store.TicketHolder(t.Context(), tokenHash("ticket-"+session))
if err != nil && !errors.Is(err, port.ErrTicketNotFound) {
t.Fatal(err)
}
return err == nil
}
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
}
deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1"))
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
}
if !valid("alice-1") || valid("alice-2") || valid("alice-3") {
t.Error("删除会话之后,只有保留的会话的票据仍然有效")
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest-4"), alice.ID, tokenHash("alice-4"), "new"); err != nil {
t.Fatal(err)
}
if valid("guest-4") {
t.Error("登录换掉游客的会话之后,游客的票据仍然有效")
}
if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil {
t.Fatal(err)
}
if valid("alice-1") {
t.Error("退出登录之后,这个会话的票据仍然有效")
}
}
// 不计顺序。
func sameHashes(got, want [][]byte) bool {
key := func(hashes [][]byte) []string {
s := make([]string, len(hashes))
for i, h := range hashes {
s[i] = string(h)
}
slices.Sort(s)
return s
}
return slices.Equal(key(got), key(want))
}
func TestCreateTicketChecksPasswordHash(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
+23 -4
View File
@@ -13,7 +13,10 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
var _ port.Tracks = (*Store)(nil)
var (
_ port.Tracks = (*Store)(nil)
_ port.TrackLookup = (*Store)(nil)
)
func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.PreparedTrack, bool, error) {
row, err := s.q.PreparedTrack(ctx, query.PreparedTrackParams{Source: string(t.Source), TrackID: t.ID})
@@ -27,6 +30,19 @@ func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.Prepared
return port.PreparedTrack{Meta: meta, Result: result, MediaKey: orEmpty(row.MediaKey), MediaSize: orZero(row.MediaSize)}, true, nil
}
func (s *Store) TrackMetas(ctx context.Context, ts []track.Track) (map[track.Track]track.Meta, error) {
sources, ids := splitTracks(ts)
rows, err := s.q.TrackMetas(ctx, query.TrackMetasParams{Sources: sources, TrackIds: ids})
if err != nil {
return nil, fmt.Errorf("读取 %d 首歌的元数据:%w", len(ts), err)
}
found := make(map[track.Track]track.Meta, len(rows))
for _, r := range rows {
found[track.Track{Source: track.Source(r.Source), ID: r.TrackID}] = storedMeta(r.Title, orEmpty(r.Artist), orEmpty(r.Album), orEmpty(r.CoverURL), orEmpty(r.CoverKey), r.PageURL, r.DurationMs)
}
return found, nil
}
func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.PreparedTrack) error {
params := query.SavePreparedTrackParams{
Source: string(t.Source),
@@ -53,9 +69,12 @@ func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.Pre
}
func prepared(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64, gain float64, lyric, lyricWords, lyricTrans string) (track.Meta, track.Prepared) {
d := time.Duration(durationMs) * time.Millisecond
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: d},
track.Prepared{Duration: d, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
meta := storedMeta(title, artist, album, coverURL, coverKey, pageURL, durationMs)
return meta, track.Prepared{Duration: meta.Duration, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
}
func storedMeta(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64) track.Meta {
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: time.Duration(durationMs) * time.Millisecond}
}
func orZero(p *int64) int64 {
@@ -1,6 +1,7 @@
package postgres
import (
"maps"
"testing"
"time"
@@ -124,3 +125,42 @@ func TestLinkTrackRoundTrip(t *testing.T) {
t.Errorf("恢复出的条目 = %+v,期望已就绪,元数据取自曲目表", snap.Waiting[0])
}
}
func TestTrackMetas(t *testing.T) {
store, _ := newStore(t)
link := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
saved := map[track.Track]port.PreparedTrack{
netease("1"): {
Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=1", Duration: time.Minute},
Result: track.Prepared{Duration: 61500 * time.Millisecond, Gain: 0.5, Lyric: "词", LyricWords: "逐字", LyricTrans: "翻译"},
},
link: {
Meta: track.Meta{Title: "a.mp3", CoverKey: "link/abc/cover.webp", PageURL: link.ID, Duration: 3 * time.Second},
Result: track.Prepared{Duration: 3 * time.Second, Gain: 1},
MediaKey: "abc",
MediaSize: 12290,
},
}
want := map[track.Track]track.Meta{}
for tr, p := range saved {
if err := store.SavePreparedTrack(t.Context(), tr, p); err != nil {
t.Fatal(err)
}
read, _, err := store.PreparedTrack(t.Context(), tr)
if err != nil {
t.Fatal(err)
}
want[tr] = read.Meta
}
if want[netease("1")].Duration != 61500*time.Millisecond {
t.Fatalf("PreparedTrack 读出的时长 = %v,期望准备得到的时长", want[netease("1")].Duration)
}
wanted := []track.Track{netease("2"), netease("1"), {Source: track.QQ, ID: "1"}, link, netease("1")}
got, err := store.TrackMetas(t.Context(), wanted)
if err != nil || !maps.Equal(got, want) {
t.Errorf("批量读取 = %#v, %v\n期望只有命中的、与 PreparedTrack 读出的元数据一致 %#v:音源和 ID 都要对上,重复的只算一次", got, err, want)
}
if got, err := store.TrackMetas(t.Context(), nil); err != nil || len(got) != 0 {
t.Errorf("什么也不查 = %#v, %v,期望空的结果", got, err)
}
}
+13
View File
@@ -198,6 +198,15 @@ func (b *Bus) PublishMember(ctx context.Context, m member.Member) error {
return err
}
func (b *Bus) PublishSessionsRevoked(ctx context.Context, sessionTokenHashes [][]byte) error {
data, err := json.Marshal(sessionTokenHashes)
if err != nil {
return err
}
_, err = b.publish(ctx, membersChannel, kindSessionsRevoked, data, nil)
return err
}
func (b *Bus) PublishPrepared(ctx context.Context, o port.PrepareOutcome) error {
data, err := json.Marshal(o)
if err != nil {
@@ -362,6 +371,10 @@ func (b *Bus) delivery(channel string, e envelope) (port.Delivery, error) {
var m member.Member
err := json.Unmarshal(e.Data, &m)
return port.MemberDelivery{Member: m}, err
case channel == membersChannel && e.Kind == kindSessionsRevoked:
var hashes [][]byte
err := json.Unmarshal(e.Data, &hashes)
return port.SessionsRevokedDelivery{SessionTokenHashes: hashes}, err
case channel == preparedChannel && e.Kind == kindPrepared:
var o port.PrepareOutcome
err := json.Unmarshal(e.Data, &o)
+19
View File
@@ -344,6 +344,25 @@ func TestPreparedOutcomes(t *testing.T) {
}
}
func TestSessionsRevoked(t *testing.T) {
client := newClient(t)
a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
for _, f := range []*busFixture{a, b} {
if err := f.bus.SubscribeInstance(t.Context()); err != nil {
t.Fatal(err)
}
}
hashes := [][]byte{{0x01, 0x02}, {0xff, 0x00, 0x7f}}
if err := a.bus.PublishSessionsRevoked(t.Context(), hashes); err != nil {
t.Fatal(err)
}
for _, f := range []*busFixture{a, b} {
if got := f.next(t); !reflect.DeepEqual(got, port.SessionsRevokedDelivery{SessionTokenHashes: hashes}) {
t.Errorf("收到 %#v,期望被删除的会话 %x", got, hashes)
}
}
}
func TestUnsubscribeRoom(t *testing.T) {
client := newClient(t)
a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
+6 -5
View File
@@ -55,11 +55,12 @@ type (
)
const (
kindEmitted = "Emitted"
kindReplied = "Replied"
kindInput = "RoomInput"
kindMember = "Member"
kindPrepared = "Prepared"
kindEmitted = "Emitted"
kindReplied = "Replied"
kindInput = "RoomInput"
kindMember = "Member"
kindSessionsRevoked = "SessionsRevoked"
kindPrepared = "Prepared"
// room.Command 的 Action 是接口,不能像 inputs 中的类型那样直接编码。
inputCommand = "Command"
)
+193
View File
@@ -0,0 +1,193 @@
package redis
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
goredis "github.com/redis/go-redis/v9"
"github.com/dihemoe/alisten/internal/app/port"
)
var (
_ port.PairingCodes = (*Codes)(nil)
_ port.GitHubLogins = (*Codes)(nil)
_ port.GitHubVerifications = (*Codes)(nil)
)
const (
pairingPrefix = "pairing:"
githubRequestPrefix = "github:request:"
githubGrantPrefix = "github:grant:"
githubVerifiedPrefix = "github:verified:"
)
// 配对码、GitHub 登录的 state 和兑换码、用 GitHub 确认身份的记录,键中只有它们的哈希。丢失时用户重新生成、重新登录或重新确认即可。
type Codes struct {
client *goredis.Client
}
func NewCodes(client *goredis.Client) *Codes {
return &Codes{client: client}
}
func codeKey(prefix string, hash []byte) string {
return prefix + hex.EncodeToString(hash)
}
type pairingRecord struct {
MemberID string `json:"memberId"`
Session []byte `json:"session"`
}
type githubRequestRecord struct {
Intent string `json:"intent"`
Client string `json:"client"`
MemberID string `json:"memberId"`
Nonce []byte `json:"nonce"`
Verifier string `json:"verifier"`
}
type githubGrantRecord struct {
githubRequestRecord
GitHubID int64 `json:"githubId"`
}
var (
intentNames = map[port.GitHubIntent]string{port.GitHubLogin: "login", port.GitHubBind: "bind", port.GitHubVerify: "verify"}
clientNames = map[port.GitHubClient]string{port.GitHubWeb: "web", port.GitHubApp: "app"}
)
func requestRecord(r port.GitHubRequest) githubRequestRecord {
return githubRequestRecord{Intent: intentNames[r.Intent], Client: clientNames[r.Client], MemberID: r.MemberID, Nonce: r.NonceHash, Verifier: r.CodeVerifier}
}
func (r githubRequestRecord) request() (port.GitHubRequest, error) {
intent, ok := keyOf(intentNames, r.Intent)
if !ok {
return port.GitHubRequest{}, fmt.Errorf("不认识的意图 %q", r.Intent)
}
client, ok := keyOf(clientNames, r.Client)
if !ok {
return port.GitHubRequest{}, fmt.Errorf("不认识的客户端 %q", r.Client)
}
return port.GitHubRequest{Intent: intent, Client: client, MemberID: r.MemberID, NonceHash: r.Nonce, CodeVerifier: r.Verifier}, nil
}
func keyOf[K comparable](names map[K]string, name string) (K, bool) {
for k, n := range names {
if n == name {
return k, true
}
}
var zero K
return zero, false
}
func (c *Codes) CreatePairing(ctx context.Context, codeHash []byte, p port.Pairing, ttl time.Duration) (bool, error) {
value, err := json.Marshal(pairingRecord{MemberID: p.MemberID, Session: p.SessionTokenHash})
if err != nil {
return false, err
}
created, err := c.client.SetNX(ctx, codeKey(pairingPrefix, codeHash), value, ttl).Result()
if err != nil {
return false, fmt.Errorf("保存配对码:%w", err)
}
return created, nil
}
func (c *Codes) TakePairing(ctx context.Context, codeHash []byte) (port.Pairing, error) {
var record pairingRecord
if err := c.take(ctx, codeKey(pairingPrefix, codeHash), &record); err != nil {
return port.Pairing{}, fmt.Errorf("取出配对码:%w", err)
}
return port.Pairing{MemberID: record.MemberID, SessionTokenHash: record.Session}, nil
}
func (c *Codes) SaveRequest(ctx context.Context, stateHash []byte, r port.GitHubRequest, ttl time.Duration) error {
if err := c.save(ctx, codeKey(githubRequestPrefix, stateHash), requestRecord(r), ttl); err != nil {
return fmt.Errorf("保存 GitHub 授权请求:%w", err)
}
return nil
}
func (c *Codes) TakeRequest(ctx context.Context, stateHash []byte) (port.GitHubRequest, error) {
var record githubRequestRecord
if err := c.take(ctx, codeKey(githubRequestPrefix, stateHash), &record); err != nil {
return port.GitHubRequest{}, fmt.Errorf("取出 GitHub 授权请求:%w", err)
}
r, err := record.request()
if err != nil {
return port.GitHubRequest{}, fmt.Errorf("取出 GitHub 授权请求:%w", err)
}
return r, nil
}
func (c *Codes) SaveGrant(ctx context.Context, codeHash []byte, g port.GitHubGrant, ttl time.Duration) error {
record := githubGrantRecord{githubRequestRecord: requestRecord(g.Request), GitHubID: g.GitHubID}
if err := c.save(ctx, codeKey(githubGrantPrefix, codeHash), record, ttl); err != nil {
return fmt.Errorf("保存 GitHub 登录的兑换码:%w", err)
}
return nil
}
func (c *Codes) TakeGrant(ctx context.Context, codeHash []byte) (port.GitHubGrant, error) {
var record githubGrantRecord
if err := c.take(ctx, codeKey(githubGrantPrefix, codeHash), &record); err != nil {
return port.GitHubGrant{}, fmt.Errorf("取出 GitHub 登录的兑换码:%w", err)
}
r, err := record.request()
if err != nil {
return port.GitHubGrant{}, fmt.Errorf("取出 GitHub 登录的兑换码:%w", err)
}
return port.GitHubGrant{Request: r, GitHubID: record.GitHubID}, nil
}
func (c *Codes) SaveVerification(ctx context.Context, tokenHash []byte, ttl time.Duration) error {
if err := c.client.Set(ctx, codeKey(githubVerifiedPrefix, tokenHash), "", ttl).Err(); err != nil {
return fmt.Errorf("保存用 GitHub 确认身份的记录:%w", err)
}
return nil
}
func (c *Codes) Verified(ctx context.Context, tokenHash []byte) (bool, error) {
n, err := c.client.Exists(ctx, codeKey(githubVerifiedPrefix, tokenHash)).Result()
if err != nil {
return false, fmt.Errorf("读取用 GitHub 确认身份的记录:%w", err)
}
return n == 1, nil
}
func (c *Codes) DeleteVerification(ctx context.Context, tokenHash []byte) error {
if err := c.client.Del(ctx, codeKey(githubVerifiedPrefix, tokenHash)).Err(); err != nil {
return fmt.Errorf("删除用 GitHub 确认身份的记录:%w", err)
}
return nil
}
func (c *Codes) save(ctx context.Context, key string, record any, ttl time.Duration) error {
value, err := json.Marshal(record)
if err != nil {
return err
}
return c.client.Set(ctx, key, value, ttl).Err()
}
// GETDEL 让取出和删除是一个原子操作:并发的两次兑换只有一次能拿到。
func (c *Codes) take(ctx context.Context, key string, record any) error {
value, err := c.client.GetDel(ctx, key).Bytes()
if errors.Is(err, goredis.Nil) {
return port.ErrCodeNotFound
}
if err != nil {
return err
}
if err := json.Unmarshal(value, record); err != nil {
return fmt.Errorf("解码存储的值:%w", err)
}
return nil
}
+137
View File
@@ -0,0 +1,137 @@
package redis
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
func hashOf(s string) []byte {
sum := sha256.Sum256([]byte(s))
return sum[:]
}
func TestPairingCodes(t *testing.T) {
client := newClient(t)
codes := NewCodes(client)
p := port.Pairing{MemberID: "00000000-0000-7000-8000-000000000001", SessionTokenHash: hashOf("session")}
created, err := codes.CreatePairing(t.Context(), hashOf("ABCD2345"), p, 5*time.Minute)
if err != nil || !created {
t.Fatalf("CreatePairing = %v, %v", created, err)
}
key := "pairing:" + hex.EncodeToString(hashOf("ABCD2345"))
if ttl := client.PTTL(t.Context(), key).Val(); ttl < 4*time.Minute || ttl > 5*time.Minute {
t.Errorf("配对码的有效期 = %v,期望 5 分钟", ttl)
}
other := port.Pairing{MemberID: "00000000-0000-7000-8000-000000000002", SessionTokenHash: hashOf("other")}
if created, err := codes.CreatePairing(t.Context(), hashOf("ABCD2345"), other, time.Minute); err != nil || created {
t.Errorf("哈希已经有配对码时 CreatePairing = %v, %v,期望不覆盖", created, err)
}
got, err := codes.TakePairing(t.Context(), hashOf("ABCD2345"))
if err != nil || got.MemberID != p.MemberID || !bytes.Equal(got.SessionTokenHash, p.SessionTokenHash) {
t.Errorf("TakePairing = %#v, %v,期望 %#v", got, err, p)
}
if _, err := codes.TakePairing(t.Context(), hashOf("ABCD2345")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("第二次取 = %v,期望 ErrCodeNotFound", err)
}
if _, err := codes.TakePairing(t.Context(), hashOf("ZZZZ2345")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("不存在的配对码 = %v,期望 ErrCodeNotFound", err)
}
if err := client.Set(t.Context(), "pairing:"+hex.EncodeToString(hashOf("BROKEN22")), "not json", time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, err := codes.TakePairing(t.Context(), hashOf("BROKEN22")); err == nil || errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("存储中的值无法解码时 = %v,期望报错", err)
}
}
func TestGitHubLogins(t *testing.T) {
client := newClient(t)
codes := NewCodes(client)
r := port.GitHubRequest{Intent: port.GitHubBind, Client: port.GitHubApp, MemberID: "00000000-0000-7000-8000-000000000001", NonceHash: hashOf("nonce"), CodeVerifier: "verifier-1"}
if err := codes.SaveRequest(t.Context(), hashOf("state"), r, 10*time.Minute); err != nil {
t.Fatal(err)
}
if ttl := client.PTTL(t.Context(), "github:request:"+hex.EncodeToString(hashOf("state"))).Val(); ttl < 9*time.Minute || ttl > 10*time.Minute {
t.Errorf("state 的有效期 = %v,期望 10 分钟", ttl)
}
if _, err := codes.TakeGrant(t.Context(), hashOf("state")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("用 state 取兑换码 = %v,期望 ErrCodeNotFound:两种键互不相通", err)
}
got, err := codes.TakeRequest(t.Context(), hashOf("state"))
if err != nil || got.Intent != r.Intent || got.Client != r.Client || got.MemberID != r.MemberID || !bytes.Equal(got.NonceHash, r.NonceHash) || got.CodeVerifier != r.CodeVerifier {
t.Errorf("TakeRequest = %#v, %v,期望 %#v", got, err, r)
}
if _, err := codes.TakeRequest(t.Context(), hashOf("state")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("第二次取 state = %v,期望 ErrCodeNotFound", err)
}
g := port.GitHubGrant{Request: port.GitHubRequest{Intent: port.GitHubLogin, Client: port.GitHubWeb, MemberID: r.MemberID, NonceHash: r.NonceHash}, GitHubID: 9007199254740993}
if err := codes.SaveGrant(t.Context(), hashOf("code"), g, time.Minute); err != nil {
t.Fatal(err)
}
if ttl := client.PTTL(t.Context(), "github:grant:"+hex.EncodeToString(hashOf("code"))).Val(); ttl < 50*time.Second || ttl > time.Minute {
t.Errorf("兑换码的有效期 = %v,期望 1 分钟", ttl)
}
gotGrant, err := codes.TakeGrant(t.Context(), hashOf("code"))
if err != nil || gotGrant.GitHubID != g.GitHubID || gotGrant.Request.Intent != port.GitHubLogin || gotGrant.Request.Client != port.GitHubWeb || gotGrant.Request.MemberID != r.MemberID || !bytes.Equal(gotGrant.Request.NonceHash, r.NonceHash) {
t.Errorf("TakeGrant = %#v, %v,期望 %#v", gotGrant, err, g)
}
if _, err := codes.TakeGrant(t.Context(), hashOf("code")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("第二次取兑换码 = %v,期望 ErrCodeNotFound", err)
}
for name, record := range map[string]string{
"意图": `{"intent":"steal","client":"web","memberId":"x","nonce":""}`,
"客户端": `{"intent":"login","client":"evil","memberId":"x","nonce":""}`,
} {
if err := client.Set(t.Context(), "github:request:"+hex.EncodeToString(hashOf("odd")), record, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, err := codes.TakeRequest(t.Context(), hashOf("odd")); err == nil || errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("存储中的%s不认识时 = %v,期望报错", name, err)
}
}
}
func TestGitHubVerifications(t *testing.T) {
client := newClient(t)
codes := NewCodes(client)
if err := codes.SaveVerification(t.Context(), hashOf("session"), 10*time.Minute); err != nil {
t.Fatal(err)
}
if ttl := client.PTTL(t.Context(), "github:verified:"+hex.EncodeToString(hashOf("session"))).Val(); ttl < 9*time.Minute || ttl > 10*time.Minute {
t.Errorf("确认的有效期 = %v,期望 10 分钟", ttl)
}
for range 2 {
if ok, err := codes.Verified(t.Context(), hashOf("session")); err != nil || !ok {
t.Errorf("Verified = %v, %v,期望读取不删除", ok, err)
}
}
if err := codes.DeleteVerification(t.Context(), hashOf("session")); err != nil {
t.Fatal(err)
}
if ok, err := codes.Verified(t.Context(), hashOf("session")); err != nil || ok {
t.Errorf("删除之后 Verified = %v, %v,期望 false", ok, err)
}
if err := codes.DeleteVerification(t.Context(), hashOf("session")); err != nil {
t.Errorf("删除不存在的确认 = %v,期望什么也不做", err)
}
g := port.GitHubGrant{Request: port.GitHubRequest{Intent: port.GitHubVerify, Client: port.GitHubWeb, MemberID: "00000000-0000-7000-8000-000000000001", NonceHash: hashOf("nonce")}, GitHubID: 42}
if err := codes.SaveGrant(t.Context(), hashOf("other"), g, time.Minute); err != nil {
t.Fatal(err)
}
if ok, err := codes.Verified(t.Context(), hashOf("other")); err != nil || ok {
t.Errorf("同一个哈希的兑换码当作确认 = %v, %v,期望 false:两种键互不相通", ok, err)
}
if got, err := codes.TakeGrant(t.Context(), hashOf("other")); err != nil || got.Request.Intent != port.GitHubVerify {
t.Errorf("TakeGrant = %#v, %v,期望意图 verify 原样取回", got, err)
}
}
+57
View File
@@ -0,0 +1,57 @@
package redis
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
goredis "github.com/redis/go-redis/v9"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.LoginDevices = (*Devices)(nil)
const devicePrefix = "device:"
// 登录设备凭证,键中只有它的哈希,值是所属成员的 ID 和签发时的密码指纹。
type Devices struct {
client *goredis.Client
}
func NewDevices(client *goredis.Client) *Devices {
return &Devices{client: client}
}
type deviceRecord struct {
MemberID string `json:"memberId"`
PasswordStamp []byte `json:"passwordStamp"`
}
func (d *Devices) SaveDevice(ctx context.Context, deviceHash []byte, memberID string, passwordStamp []byte, ttl time.Duration) error {
value, err := json.Marshal(deviceRecord{MemberID: memberID, PasswordStamp: passwordStamp})
if err != nil {
return err
}
if err := d.client.Set(ctx, codeKey(devicePrefix, deviceHash), value, ttl).Err(); err != nil {
return fmt.Errorf("保存登录设备凭证:%w", err)
}
return nil
}
func (d *Devices) DeviceMember(ctx context.Context, deviceHash []byte) (string, []byte, error) {
value, err := d.client.Get(ctx, codeKey(devicePrefix, deviceHash)).Bytes()
if errors.Is(err, goredis.Nil) {
return "", nil, port.ErrDeviceNotFound
}
if err != nil {
return "", nil, fmt.Errorf("读取登录设备凭证:%w", err)
}
var record deviceRecord
if err := json.Unmarshal(value, &record); err != nil {
return "", nil, fmt.Errorf("读取登录设备凭证:解码存储的值:%w", err)
}
return record.MemberID, record.PasswordStamp, nil
}
@@ -0,0 +1,57 @@
package redis
import (
"bytes"
"encoding/hex"
"errors"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
func TestLoginDevices(t *testing.T) {
client := newClient(t)
devices := NewDevices(client)
const alice, bob = "00000000-0000-7000-8000-000000000001", "00000000-0000-7000-8000-000000000002"
aliceStamp, bobStamp := hashOf("alice password")[:16], hashOf("bob password")[:16]
if _, _, err := devices.DeviceMember(t.Context(), hashOf("alice-device")); !errors.Is(err, port.ErrDeviceNotFound) {
t.Errorf("没有保存过的凭证 = %v,期望 ErrDeviceNotFound", err)
}
if err := devices.SaveDevice(t.Context(), hashOf("alice-device"), alice, aliceStamp, time.Minute); err != nil {
t.Fatal(err)
}
if err := devices.SaveDevice(t.Context(), hashOf("bob-device"), bob, bobStamp, time.Minute); err != nil {
t.Fatal(err)
}
for device, want := range map[string]struct {
member string
stamp []byte
}{"alice-device": {alice, aliceStamp}, "bob-device": {bob, bobStamp}} {
if member, stamp, err := devices.DeviceMember(t.Context(), hashOf(device)); err != nil || member != want.member || !bytes.Equal(stamp, want.stamp) {
t.Errorf("%s = %q, %x, %v,期望 %q, %x", device, member, stamp, err, want.member, want.stamp)
}
}
changed := hashOf("alice new password")[:16]
if err := devices.SaveDevice(t.Context(), hashOf("alice-device"), alice, changed, 180*24*time.Hour); err != nil {
t.Fatal(err)
}
if member, stamp, err := devices.DeviceMember(t.Context(), hashOf("alice-device")); err != nil || member != alice || !bytes.Equal(stamp, changed) {
t.Errorf("再次保存之后 = %q, %x, %v,期望 %q, %x", member, stamp, err, alice, changed)
}
ttl := client.PTTL(t.Context(), "device:"+hex.EncodeToString(hashOf("alice-device"))).Val()
if ttl < 180*24*time.Hour-time.Minute || ttl > 180*24*time.Hour {
t.Errorf("再次保存之后的有效期 = %v,期望从现在重新计为 180 天", ttl)
}
if ttl := client.PTTL(t.Context(), "device:"+hex.EncodeToString(hashOf("bob-device"))).Val(); ttl < 50*time.Second || ttl > time.Minute {
t.Errorf("另一个凭证的有效期 = %v,期望不受影响,仍是 1 分钟", ttl)
}
if err := client.Set(t.Context(), "device:"+hex.EncodeToString(hashOf("broken-device")), alice, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, _, err := devices.DeviceMember(t.Context(), hashOf("broken-device")); err == nil || errors.Is(err, port.ErrDeviceNotFound) {
t.Errorf("存储中的值无法解码时 = %v,期望报错", err)
}
}
+27 -1
View File
@@ -31,10 +31,36 @@ func (q *RoomQuota) TakePick(ctx context.Context, roomID string, burst int, refi
}
func (q *RoomQuota) take(ctx context.Context, roomID, bucket, what string, burst int, refill time.Duration) (time.Duration, error) {
res, err := q.limiter.Allow(ctx, "room:"+roomID+":"+bucket, redis_rate.Limit{Rate: 1, Burst: burst, Period: refill})
wait, err := take(ctx, q.limiter, "room:"+roomID+":"+bucket, burst, refill)
if err != nil {
return 0, fmt.Errorf("取房间 %s 的%s额度:%w", roomID, what, err)
}
return wait, nil
}
var _ port.AccountQuota = (*AccountQuota)(nil)
type AccountQuota struct {
limiter *redis_rate.Limiter
}
func NewAccountQuota(client *goredis.Client) *AccountQuota {
return &AccountQuota{limiter: redis_rate.NewLimiter(client)}
}
func (q *AccountQuota) Take(ctx context.Context, key string, burst int, refill time.Duration) (time.Duration, error) {
wait, err := take(ctx, q.limiter, "account:"+key, burst, refill)
if err != nil {
return 0, fmt.Errorf("取 %s 的额度:%w", key, err)
}
return wait, nil
}
func take(ctx context.Context, limiter *redis_rate.Limiter, key string, burst int, refill time.Duration) (time.Duration, error) {
res, err := limiter.Allow(ctx, key, redis_rate.Limit{Rate: 1, Burst: burst, Period: refill})
if err != nil {
return 0, err
}
if res.Allowed > 0 {
return 0, nil
}
+23
View File
@@ -43,3 +43,26 @@ func TestTakePick(t *testing.T) {
t.Errorf("另一个房间 = %v, %v,点歌额度按房间计算", wait, err)
}
}
func TestAccountQuota(t *testing.T) {
client := newClient(t)
quota := NewAccountQuota(client)
for i := range 2 {
if wait, err := quota.Take(t.Context(), "login:ip:203.0.113.7", 2, time.Minute); err != nil || wait != 0 {
t.Fatalf("第 %d 次 = %v, %v,突发额度之内应直接取到", i+1, wait, err)
}
}
wait, err := quota.Take(t.Context(), "login:ip:203.0.113.7", 2, time.Minute)
if err != nil {
t.Fatal(err)
}
if wait < 59*time.Second || wait > time.Minute {
t.Errorf("额度用完后要等 %v,期望约 1 分钟", wait)
}
if wait, err := quota.Take(t.Context(), "login:user:alice", 2, time.Minute); err != nil || wait != 0 {
t.Errorf("另一个键 = %v, %v,额度按键计算", wait, err)
}
if wait, err := NewRoomQuota(client).TakeSearch(t.Context(), "login:ip:203.0.113.7", 1, time.Minute); err != nil || wait != 0 {
t.Errorf("房间额度 = %v, %v,账号额度的键不能与房间额度的键重合", wait, err)
}
}
@@ -0,0 +1,85 @@
package shortlink
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
const (
maxRedirects = 5
timeout = 5 * time.Second
)
type Resolver struct {
allowed func(host string) bool
done func(link string) bool
client *http.Client
}
var _ port.ShortLinks = (*Resolver)(nil)
// allowed 检查每一跳(包括起点)的主机。跳转到 done 认得的地址时就停下,不再请求它:
// 网易云的短链接最后一跳是很慢的歌单页面,逐跳请求到底会超过总超时。
func New(allowed func(host string) bool, done func(link string) bool) *Resolver {
return &Resolver{
allowed: allowed,
done: done,
// 跳转由 Resolve 逐跳处理,这样每一跳的主机都能先检查再发请求。
client: &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
}
}
func (r *Resolver) Resolve(ctx context.Context, link string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
current := link
for hop := 0; ; hop++ {
u, err := url.Parse(current)
if err != nil {
return "", fmt.Errorf("解析链接: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("不支持的协议 %q", u.Scheme)
}
if !r.allowed(u.Hostname()) {
return "", fmt.Errorf("跳转到不受信任的主机 %q", u.Hostname())
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, current, nil)
if err != nil {
return "", err
}
resp, err := r.client.Do(req)
if err != nil {
return "", fmt.Errorf("请求短链接: %w", err)
}
_ = resp.Body.Close()
switch resp.StatusCode {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther,
http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
default:
return current, nil
}
if hop == maxRedirects {
return "", errors.New("跳转次数过多")
}
next, err := resp.Location()
if err != nil {
return "", fmt.Errorf("读取跳转地址: %w", err)
}
current = next.String()
if r.done(current) {
return current, nil
}
}
}
@@ -0,0 +1,131 @@
package shortlink
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/synctest"
"time"
)
func allowLoopback(host string) bool { return host == "127.0.0.1" }
func never(string) bool { return false }
func TestResolve(t *testing.T) {
var methods []string
mux := http.NewServeMux()
mux.HandleFunc("/short", func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
http.Redirect(w, r, "/mid", http.StatusFound)
})
mux.HandleFunc("/mid", func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
http.Redirect(w, r, "/playlist?id=1", http.StatusMovedPermanently)
})
mux.HandleFunc("/playlist", func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
_, _ = w.Write([]byte("body"))
})
mux.HandleFunc("/l", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, r.URL.Path+"x", http.StatusFound)
})
mux.HandleFunc("/lx", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/l", http.StatusFound)
})
mux.HandleFunc("/five", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f2", http.StatusFound) })
mux.HandleFunc("/f2", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f3", http.StatusFound) })
mux.HandleFunc("/f3", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f4", http.StatusFound) })
mux.HandleFunc("/f4", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f5", http.StatusFound) })
mux.HandleFunc("/f5", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/playlist?id=2", http.StatusFound) })
mux.HandleFunc("/evil", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://localhost:1/x", http.StatusFound)
})
srv := httptest.NewServer(mux)
defer srv.Close()
r := New(allowLoopback, never)
ctx := context.Background()
t.Run("正常跳转且只发 GET", func(t *testing.T) {
methods = nil
got, err := r.Resolve(ctx, srv.URL+"/short")
if err != nil {
t.Fatal(err)
}
if want := srv.URL + "/playlist?id=1"; got != want {
t.Fatalf("got %q; want %q", got, want)
}
if strings.Join(methods, ",") != "GET,GET,GET" {
t.Fatalf("methods = %v", methods)
}
})
t.Run("跳到认得的地址时不再请求它", func(t *testing.T) {
methods = nil
recognized := New(allowLoopback, func(link string) bool { return strings.Contains(link, "/playlist?") })
got, err := recognized.Resolve(ctx, srv.URL+"/short")
if err != nil {
t.Fatal(err)
}
if want := srv.URL + "/playlist?id=1"; got != want {
t.Fatalf("got %q; want %q", got, want)
}
if strings.Join(methods, ",") != "GET,GET" {
t.Fatalf("methods = %v,期望不请求 /playlist", methods)
}
})
t.Run("五次跳转可以", func(t *testing.T) {
got, err := r.Resolve(ctx, srv.URL+"/five")
if err != nil {
t.Fatal(err)
}
if want := srv.URL + "/playlist?id=2"; got != want {
t.Fatalf("got %q; want %q", got, want)
}
})
t.Run("超过五次", func(t *testing.T) {
if _, err := r.Resolve(ctx, srv.URL+"/l"); err == nil {
t.Fatal("want error")
}
})
t.Run("跳到不允许的主机", func(t *testing.T) {
if _, err := r.Resolve(ctx, srv.URL+"/evil"); err == nil {
t.Fatal("want error")
}
})
t.Run("起点不允许", func(t *testing.T) {
if _, err := New(func(string) bool { return false }, never).Resolve(ctx, srv.URL+"/playlist"); err == nil {
t.Fatal("want error")
}
})
t.Run("非 http 协议", func(t *testing.T) {
if _, err := r.Resolve(ctx, "file:///etc/passwd"); err == nil {
t.Fatal("want error")
}
})
}
// 不响应的上游:请求一直挂着,直到请求的 ctx 结束。
type hangingTransport struct{}
func (hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
}
func TestResolveTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
r := New(allowLoopback, never)
r.client.Transport = hangingTransport{}
start := time.Now()
if _, err := r.Resolve(t.Context(), "http://127.0.0.1/slow"); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("上游不响应时的错误 = %v,期望超过期限", err)
}
if elapsed := time.Since(start); elapsed != 5*time.Second {
t.Errorf("等了 %v 才中止,期望 5 秒", elapsed)
}
})
}
+3 -6
View File
@@ -92,7 +92,6 @@ type RoomChange struct {
// id 必须是 UUID。名称和描述由房间修改,房间落盘之后才返回。设置或修改密码会作废房间成员的票据并断开会话和成员的事件流,
// 这一步无法撤销,所以放在最后:前面任何一步失败时票据仍然有效,重试也不会再次作废重新签发的票据。
// 断开的通知发不出去时返回错误,此时票据已经作废。
// 别名在名称和描述之前修改,别名已被占用时不做任何修改。取消密码不影响已有的票据。
func (s *Service) UpdateRoom(ctx context.Context, id string, c RoomChange) error {
r, err := s.persistentRoom(ctx, id)
@@ -126,7 +125,8 @@ func (s *Service) UpdateRoom(ctx context.Context, id string, c RoomChange) error
if *hash == "" {
return nil
}
return s.gateway.Revoke(ctx, r.Info.ID)
s.gateway.Revoke(ctx, r.Info.ID)
return nil
}
// id 必须是 UUID。
@@ -148,7 +148,6 @@ func (s *Service) RoomAPIKeyStatus(ctx context.Context, id string) (port.APIKeyS
}
// id 必须是 UUID。还没有 Key 时生成,已经有时旧 Key 立即失效,用旧 Key 打开的事件流随之断开。
// 断开事件流的通知发不出去时返回错误,此时 Key 已经重置。
func (s *Service) ResetRoomAPIKey(ctx context.Context, id string) (string, error) {
r, err := s.persistentRoom(ctx, id)
if err != nil {
@@ -158,9 +157,7 @@ func (s *Service) ResetRoomAPIKey(ctx context.Context, id string) (string, error
if err := s.keys.ResetRoomAPIKey(ctx, r.Info.ID, k); err != nil {
return "", err
}
if err := s.gateway.RevokeAPIKey(ctx, r.Info.ID); err != nil {
return "", err
}
s.gateway.RevokeAPIKey(ctx, r.Info.ID)
return key, nil
}
+27 -14
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"errors"
"log/slog"
"reflect"
"strconv"
"strings"
"testing"
@@ -33,7 +34,7 @@ func newService(t *testing.T, token string) fixture {
t.Helper()
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))
host, gateway := realtime.New(store, store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
@@ -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, hash[:])
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)
}
}
@@ -466,24 +467,29 @@ func TestBilibiliQRLogin(t *testing.T) {
}
}
func TestUpdateRoomReportsFailedRevoke(t *testing.T) {
// 票据已经作废,断开的通知发不出去时照常成功:用旧票据建立的连接由网关定期核对凭证时断开。
func TestUpdateRoomSucceedsWhenRevokeFails(t *testing.T) {
f := newService(t, "secret")
id, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "大厅"})
if err != nil {
t.Fatal(err)
}
ticket, _ := f.enter(t, id, "alice")
f.node.SetDown(true)
err = f.admin.UpdateRoom(t.Context(), id, RoomChange{Password: ptr("芝麻开门")})
f.node.SetDown(false)
if !errors.Is(err, realtime.ErrUnavailable) {
t.Errorf("通知发不出去时的错误 = %v,期望 ErrUnavailable", err)
if err != nil {
t.Errorf("通知发不出去时 UpdateRoom = %v,期望成功", err)
}
if r, err := f.store.FindRoom(t.Context(), id); err != nil || r.PasswordHash == "" {
t.Errorf("通知之前密码已经设置好,房间 = %#v, %v", r, err)
t.Errorf("密码应已经设置好,房间 = %#v, %v", r, err)
}
if _, err := f.store.TicketHolder(t.Context(), ticket); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("成员票据的错误 = %v,期望 ErrTicketNotFound", err)
}
}
func TestResetRoomAPIKeyReportsFailedRevoke(t *testing.T) {
func TestResetRoomAPIKeySucceedsWhenRevokeFails(t *testing.T) {
f := newService(t, "secret")
id, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "大厅"})
if err != nil {
@@ -496,11 +502,18 @@ func TestResetRoomAPIKeyReportsFailedRevoke(t *testing.T) {
f.node.SetDown(true)
renewed, err := f.admin.ResetRoomAPIKey(t.Context(), id)
f.node.SetDown(false)
if renewed != "" || !errors.Is(err, realtime.ErrUnavailable) {
t.Errorf("通知发不出去时 = %q, %v,期望不返回 Key,错误为 ErrUnavailable", renewed, err)
if err != nil || renewed == "" || renewed == key {
t.Fatalf("通知发不出去时 ResetRoomAPIKey = %q, %v,期望返回新 Key", renewed, err)
}
hash := sha256.Sum256([]byte(key))
if _, err := f.store.TicketHolder(t.Context(), hash[:]); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("通知之前旧 Key 已经失效,它的错误 = %v,期望 ErrTicketNotFound", err)
holder := func(key string) error {
hash := sha256.Sum256([]byte(key))
_, err := f.store.TicketHolder(t.Context(), hash[:])
return err
}
if err := holder(key); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("旧 Key 的错误 = %v,期望 ErrTicketNotFound", err)
}
if err := holder(renewed); err != nil {
t.Errorf("新 Key 的错误 = %v,期望有效", err)
}
}
+8
View File
@@ -84,6 +84,14 @@ func (s *Service) PlaylistTracks(ctx context.Context, roomID string, source trac
if err := s.take(ctx, roomID); err != nil {
return nil, err
}
return s.PlaylistSongs(ctx, source, playlistID)
}
// 不取房间的搜索额度:导入歌单按成员另外限流。
func (s *Service) PlaylistSongs(ctx context.Context, source track.Source, playlistID string) ([]port.Song, error) {
if source != track.NetEase {
return nil, unsupported(source, "歌单内的歌曲")
}
songs, err := s.netease.PlaylistTracks(ctx, playlistID)
if err != nil {
return nil, upstream(source, "获取歌单内的歌曲", err)
+20
View File
@@ -244,6 +244,26 @@ func TestFirstMatch(t *testing.T) {
}
}
func TestPlaylistSongsSkipsRoomQuota(t *testing.T) {
f := newFixture(t, history{})
songs := []port.Song{song(track.NetEase, "1", "晴天"), song(track.NetEase, "2", "七里香")}
f.add(f.netease, songs...)
f.netease.AddPlaylist(port.Playlist{ID: "p1", Name: "周杰伦"}, "2", "1")
if got, err := f.svc.PlaylistSongs(t.Context(), track.NetEase, "p1"); err != nil || !reflect.DeepEqual(got, []port.Song{songs[1], songs[0]}) {
t.Errorf("歌单内的歌曲 = %v, %v,期望按歌单的顺序", got, err)
}
if f.quota.calls != 0 {
t.Errorf("取了 %d 次房间的搜索额度,导入不走房间的额度", f.quota.calls)
}
if _, err := f.svc.PlaylistSongs(t.Context(), track.QQ, "p1"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("QQ 音乐的歌单 = %v,期望 ErrProviderFailed", err)
}
f.netease.Fail(errors.New("网易云不可用"))
if _, err := f.svc.PlaylistSongs(t.Context(), track.NetEase, "p1"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("上游出错时的错误 = %v,期望 ErrProviderFailed", err)
}
}
func TestTrackMetaOfBilibili(t *testing.T) {
f := newFixture(t, history{})
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
+370
View File
@@ -0,0 +1,370 @@
package identity
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/netip"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/internal/domain/member"
)
var (
ErrRegistrationClosed = errors.New("注册已关闭")
ErrAlreadyLoggedIn = errors.New("已经登录了账号,先退出再登录其他账号")
ErrInvalidCredentials = errors.New("用户名或密码错误")
ErrWrongCurrentPassword = errors.New("当前密码错误")
ErrNotRegistered = errors.New("还没有注册账号")
ErrTooManyAttempts = errors.New("尝试次数太多,请稍后再试。")
ErrAccountNotFound = errors.New("没有这个用户名的账号")
)
// 注册、登录和修改密码都要做一次 argon2id 计算:额度既防止猜密码,也防止耗尽计算槽位。
const (
registerBurst = 5
registerRefill = 10 * time.Minute
loginIPBurst = 10
loginIPRefill = time.Minute
loginUserBurst = 5
loginUserRefill = time.Minute
passwordBurst = 5
passwordRefill = time.Minute
)
const loginDeviceTTL = 180 * 24 * time.Hour
// 只绑定了 GitHub 的账号没有用户名,也没有密码。
type Account struct {
Username string
Email string
GitHub bool
}
func accountOf(a port.Account) Account {
return Account{Username: a.Username, Email: a.Email, GitHub: a.GitHubID != 0}
}
// 删除会话之后,用这些会话的票据建立的房间连接随之断开:网关立即通知各实例,通知丢失时最迟在网关的一个核对周期之后断开。
type Accounts struct {
members port.Members
site port.Site
quota port.AccountQuota
pairings port.PairingCodes
verifications port.GitHubVerifications
devices port.LoginDevices
gateway *realtime.Gateway
}
func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, pairings port.PairingCodes, verifications port.GitHubVerifications, devices port.LoginDevices, gateway *realtime.Gateway) *Accounts {
return &Accounts{members: members, site: site, quota: quota, pairings: pairings, verifications: verifications, devices: devices, gateway: gateway}
}
// 游客没有账号,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, GitHub: profile.GitHub}, profile.Registered(), nil
}
// 给游客设置用户名和密码,成员和它的会话都不变;只绑定了 GitHub 的账号也用它补设用户名和密码,见 addCredentials。
// token 是发出请求的成员 token,username 和 password 已经按协议校验过格式。
func (a *Accounts) Register(ctx context.Context, memberID, token, clientIP, username, password string) (Account, error) {
profile, err := a.members.AccountProfile(ctx, memberID)
if err != nil {
return Account{}, err
}
switch {
case profile.Username != "":
return Account{}, port.ErrAlreadyRegistered
case profile.GitHub:
return a.addCredentials(ctx, memberID, token, clientIP, username, password, profile.Email)
}
settings, err := a.site.Settings(ctx)
if err != nil {
return Account{}, err
}
if !settings.RegistrationOpen {
return Account{}, ErrRegistrationClosed
}
if err := a.take(ctx, "register:ip:"+quotaAddr(clientIP), registerBurst, registerRefill); err != nil {
return Account{}, err
}
hash, err := HashPassword(ctx, password)
if err != nil {
return Account{}, err
}
if err := a.members.Register(ctx, memberID, username, hash); err != nil {
return Account{}, err
}
return Account{Username: username, Email: profile.Email, GitHub: profile.GitHub}, nil
}
// 拿到会话的人不能抢先给只绑定了 GitHub 的账号设成自己的密码:发出请求的会话要在 10 分钟内用账号绑定的 GitHub 确认过身份
// (GitHub.Exchange 的 verify)。补设之后,账号除了这个会话,其他会话都失效,与修改密码相同:对方随之下线。
// 补设不是注册新账号,注册关闭时照样可以。
func (a *Accounts) addCredentials(ctx context.Context, memberID, token, clientIP, username, password, email string) (Account, error) {
tokenHash := hashSecret(token)
verified, err := a.verifications.Verified(ctx, tokenHash)
if err != nil {
return Account{}, err
}
if !verified {
return Account{}, ErrGitHubUnverified
}
if err := a.take(ctx, "register:ip:"+quotaAddr(clientIP), registerBurst, registerRefill); err != nil {
return Account{}, err
}
hash, err := HashPassword(ctx, password)
if err != nil {
return Account{}, err
}
// 失败时(比如用户名已被占用)确认保留,换个用户名可以重试;同一个账号只能补设一次,由存储保证。
deleted, err := a.members.AddCredentials(ctx, memberID, username, hash, tokenHash)
if err != nil {
return Account{}, err
}
a.gateway.RevokeSessions(ctx, deleted)
// 账号已经有了用户名,这次确认再也用不上:删不掉也只是等它过期,不影响补设的结果。
_ = a.verifications.DeleteVerification(ctx, tokenHash)
return Account{Username: username, Email: email, GitHub: true}, nil
}
// current 是发出请求的成员,currentToken 是它的成员 token。只有游客能登录:成功后签发账号的新会话,游客的这个会话随即失效。
// device 是这台设备此前登录时拿到的登录设备凭证,没有时为空字符串,已经按协议校验过格式。成功时返回这台设备此后要带上的凭证:
// 带来的凭证属于这个账号、签发之后密码也没变过时续期并原样返回,否则签发新的。
func (a *Accounts) Login(ctx context.Context, current member.Member, currentToken, clientIP, username, password, device string) (Session, Account, string, error) {
if err := a.requireGuest(ctx, current.ID); err != nil {
return Session{}, Account{}, "", err
}
// 按 IP 限制一个来源的尝试次数,按用户名限制一个账号被尝试的次数:换着 IP 猜同一个账号也会受限。
// 带着这个账号的凭证时改按凭证计数:别人反复猜密码耗尽了用户名额度,账号主人的设备仍然能登录。
if err := a.take(ctx, "login:ip:"+quotaAddr(clientIP), loginIPBurst, loginIPRefill); err != nil {
return Session{}, Account{}, "", err
}
known, err := a.knownDevice(ctx, device, username)
if err != nil {
return Session{}, Account{}, "", err
}
quotaKey := "login:user:" + username
if known {
quotaKey = "login:device:" + hex.EncodeToString(hashSecret(device))
}
if err := a.take(ctx, quotaKey, loginUserBurst, loginUserRefill); err != nil {
return Session{}, Account{}, "", err
}
account, err := a.verify(ctx, username, password)
if err != nil {
return Session{}, Account{}, "", err
}
if !known {
device, _ = newSecret()
}
// 先存凭证再签发会话:存不了时游客的会话还在,可以重试;签发会话失败时留下的凭证到期自行失效。
if err := a.devices.SaveDevice(ctx, hashSecret(device), account.Member.ID, passwordStamp(account.PasswordHash), loginDeviceTTL); err != nil {
return Session{}, Account{}, "", err
}
session, err := a.signIn(ctx, currentToken, account)
if errors.Is(err, port.ErrAccountPasswordChanged) {
return Session{}, Account{}, "", ErrInvalidCredentials
}
if err != nil {
return Session{}, Account{}, "", err
}
return session, accountOf(account), device, nil
}
// 未知、过期、属于别的账号,或者签发之后账号的密码变过(修改、重置、补设)的凭证,都当作没带。
func (a *Accounts) knownDevice(ctx context.Context, device, username string) (bool, error) {
if device == "" {
return false, nil
}
memberID, stamp, err := a.devices.DeviceMember(ctx, hashSecret(device))
if errors.Is(err, port.ErrDeviceNotFound) {
return false, nil
}
if err != nil {
return false, err
}
account, err := a.members.AccountByUsername(ctx, username)
if errors.Is(err, port.ErrMemberNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return account.Member.ID == memberID && bytes.Equal(stamp, passwordStamp(account.PasswordHash)), nil
}
// 登录设备凭证要在改密码之后失效,所以绑定签发时的密码:否则知道旧密码的人可以反复登录先囤下一批凭证,
// 等账号改了密码,每个凭证仍各有一份独立的猜测额度,按用户名的额度就被绕过了。凭证只存密码哈希的指纹,不存哈希本身。
func passwordStamp(passwordHash string) []byte {
sum := sha256.Sum256([]byte(passwordHash))
return sum[:16]
}
// 只有游客能登录:已经登录了账号的设备要先退出。
func (a *Accounts) requireGuest(ctx context.Context, memberID string) error {
if _, registered, err := a.Account(ctx, memberID); err != nil {
return err
} else if registered {
return ErrAlreadyLoggedIn
}
return nil
}
// 给发出请求的游客签发 account 的新会话,游客的这个会话随即失效。account.PasswordHash 是校验时读到的密码哈希,
// 此后密码被修改或重置过时返回 port.ErrAccountPasswordChanged,由调用方按各自的登录方式告诉用户。
func (a *Accounts) signIn(ctx context.Context, currentToken string, account port.Account) (Session, error) {
token, hash := newSecret()
guest := hashSecret(currentToken)
err := a.members.ReplaceSession(ctx, guest, account.Member.ID, hash, account.PasswordHash)
if errors.Is(err, port.ErrSessionNotFound) {
return Session{}, ErrInvalidMemberToken
}
if err != nil {
return Session{}, err
}
a.gateway.RevokeSessions(ctx, [][]byte{guest})
return Session{MemberToken: token, Member: account.Member}, 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) || (err == nil && account.PasswordHash == "") {
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
}
a.gateway.RevokeSessions(ctx, [][]byte{hash})
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
switch {
case profile.GitHub && username == "":
return port.ErrNoPassword
case username == "":
return ErrNotRegistered
}
account, err := a.checkCurrentPassword(ctx, memberID, username, currentPassword)
if err != nil {
return err
}
hash, err := HashPassword(ctx, newPassword)
if err != nil {
return err
}
// 校验之后密码可能被重置或被别的设备改过:发出请求的会话已经被删掉时按 token 失效处理,同一个会话先改过时当前密码已经不对。
deleted, err := a.members.ChangePassword(ctx, memberID, account.PasswordHash, hash, hashSecret(token))
switch {
case errors.Is(err, port.ErrSessionNotFound):
return ErrInvalidMemberToken
case errors.Is(err, port.ErrAccountPasswordChanged):
return ErrWrongCurrentPassword
case err != nil:
return err
}
a.gateway.RevokeSessions(ctx, deleted)
return nil
}
// 修改密码、绑定和解绑 GitHub 都要输入当前密码:只凭一个会话不能更换登录凭证,否则拿到会话的人换上自己的凭证之后,
// 本人修改密码、后台重置密码都赶不走他。额度按成员计数,这几种操作共用。
func (a *Accounts) checkCurrentPassword(ctx context.Context, memberID, username, password string) (port.Account, error) {
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
return port.Account{}, err
}
account, err := a.verify(ctx, username, password)
if errors.Is(err, ErrInvalidCredentials) {
return port.Account{}, ErrWrongCurrentPassword
}
return account, err
}
// Find 和 ResetPassword 只供后台使用,调用方要先校验管理 token。
func (a *Accounts) Find(ctx context.Context, username string) (port.Account, error) {
account, err := a.members.AccountByUsername(ctx, username)
if errors.Is(err, port.ErrMemberNotFound) {
return port.Account{}, ErrAccountNotFound
}
return account, err
}
// 生成随机的新密码,账号在所有设备上的会话随即失效。新密码只出现在返回值中。
func (a *Accounts) ResetPassword(ctx context.Context, username string) (string, error) {
account, err := a.Find(ctx, username)
if err != nil {
return "", err
}
password := newPassword()
hash, err := HashPassword(ctx, password)
if err != nil {
return "", err
}
deleted, err := a.members.SetPassword(ctx, account.Member.ID, hash)
if err != nil {
return "", err
}
a.gateway.RevokeSessions(ctx, deleted)
return password, nil
}
// IPv6 客户端通常分到整个 /64,可以在里面随意换地址,所以按 /64 计数。clientIP 由适配器给出,解析不了时原样使用。
func quotaAddr(clientIP string) string {
addr, err := netip.ParseAddr(clientIP)
if err != nil {
return clientIP
}
if addr = addr.Unmap(); addr.Is4() {
return addr.String()
}
return netip.PrefixFrom(addr, 64).Masked().String()
}
func (a *Accounts) take(ctx context.Context, key string, burst int, refill time.Duration) error {
wait, err := a.quota.Take(ctx, key, burst, refill)
if err != nil {
return err
}
if wait > 0 {
return ErrTooManyAttempts
}
return nil
}
File diff suppressed because it is too large. Load diff
+272
View File
@@ -0,0 +1,272 @@
package identity
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
var (
ErrGitHubRequestExpired = errors.New("GitHub 授权已经过期,请重新发起")
ErrGitHubFailed = errors.New("没有从 GitHub 确认你的身份,请稍后再试")
ErrGitHubLoginExpired = errors.New("GitHub 登录已经失效,请重新发起")
ErrGitHubMismatch = errors.New("需要用这个账号绑定的 GitHub 确认,请在 GitHub 上换成绑定的账号再试")
ErrGitHubUnverified = errors.New("先用这个账号绑定的 GitHub 确认身份,再设置用户名和密码")
ErrGitHubDisabled = errors.New("站点没有开启 GitHub 登录")
)
const (
githubRequestTTL = 10 * time.Minute
githubGrantTTL = time.Minute
// 用 GitHub 确认身份之后,要在这段时间内补设用户名和密码。
githubVerificationTTL = 10 * time.Minute
// 每次发起授权都在 Redis 中保存一个 10 分钟的 state,额度与登录相同。
githubAuthorizeBurst = 10
githubAuthorizeRefill = time.Minute
)
// 后台配置了 GitHub OAuth App 才能使用,没有配置时 Authorize、Callback、Exchange 和 Unbind 都返回 ErrGitHubDisabled。它们每次都重新读取
// 配置,不缓存:后台的修改在所有实例上立即生效,实例之间不必同步。流程:Authorize 记下意图、客户端、nonce 和 PKCE 的 verifier,返回
// GitHub 的授权地址;GitHub 回调时 Callback 换取用户 ID,生成一次性的兑换码,GitHub 没有授权时 Cancel 作废 state,两者都返回记下的
// 客户端,回调跳回它;发起授权的客户端用兑换码和它保存的 nonce 调用 Exchange,按意图登录、注册、绑定或确认身份。
type GitHub struct {
accounts *Accounts
apps port.GitHubOAuthApps
oauth port.GitHubOAuth
logins port.GitHubLogins
}
func NewGitHub(accounts *Accounts, apps port.GitHubOAuthApps, oauth port.GitHubOAuth, logins port.GitHubLogins) *GitHub {
return &GitHub{accounts: accounts, apps: apps, oauth: oauth, logins: logins}
}
// 没有配置时返回空字符串。不提供读取 Client Secret 的方法:它只用来向 GitHub 换取令牌。
func (g *GitHub) ClientID(ctx context.Context) (string, error) {
app, err := g.apps.GitHubOAuthApp(ctx)
if err != nil {
return "", err
}
return app.ClientID, nil
}
// Client ID 和 Client Secret 必须已经校验过,都不为空。
func (g *GitHub) SetOAuthApp(ctx context.Context, app port.GitHubOAuthApp) error {
return g.apps.SetGitHubOAuthApp(ctx, app)
}
// 关闭 GitHub 登录。已经绑定的账号保持绑定,重新配置之后照常登录。
func (g *GitHub) DeleteOAuthApp(ctx context.Context) error {
return g.apps.DeleteGitHubOAuthApp(ctx)
}
func (g *GitHub) app(ctx context.Context) (port.GitHubOAuthApp, error) {
app, err := g.apps.GitHubOAuthApp(ctx)
if err != nil {
return port.GitHubOAuthApp{}, err
}
if app.ClientID == "" {
return port.GitHubOAuthApp{}, ErrGitHubDisabled
}
return app, nil
}
// nonce 由发起授权的客户端生成并保存,兑换时要原样带上。
func (g *GitHub) Authorize(ctx context.Context, memberID, clientIP string, intent port.GitHubIntent, client port.GitHubClient, nonce, currentPassword string) (string, error) {
app, err := g.app(ctx)
if err != nil {
return "", err
}
profile, err := g.accounts.members.AccountProfile(ctx, memberID)
if err != nil {
return "", err
}
switch {
case intent == port.GitHubLogin && profile.Registered():
return "", ErrAlreadyLoggedIn
case intent == port.GitHubBind && !profile.Registered():
return "", ErrNotRegistered
case intent == port.GitHubBind && profile.GitHub:
return "", port.ErrGitHubAlreadyBound
case intent == port.GitHubVerify && !profile.Registered():
return "", ErrNotRegistered
case intent == port.GitHubVerify && profile.Username != "":
return "", port.ErrAlreadyRegistered
}
// 能发起绑定的账号没有绑定 GitHub,一定设置过密码。
if intent == port.GitHubBind {
if _, err := g.accounts.checkCurrentPassword(ctx, memberID, profile.Username, currentPassword); err != nil {
return "", err
}
}
if err := g.accounts.take(ctx, "github:ip:"+quotaAddr(clientIP), githubAuthorizeBurst, githubAuthorizeRefill); err != nil {
return "", err
}
state, stateHash := newSecret()
verifier, _ := newSecret()
request := port.GitHubRequest{Intent: intent, Client: client, MemberID: memberID, NonceHash: hashSecret(nonce), CodeVerifier: verifier}
if err := g.logins.SaveRequest(ctx, stateHash, request, githubRequestTTL); err != nil {
return "", err
}
return g.oauth.AuthorizeURL(app, state, verifier), nil
}
// 只换取 GitHub 用户 ID,不修改账号,返回发起授权的客户端和一次性的兑换码。登录、注册和绑定都留给 Exchange:回调请求不带成员 token
// 和 nonce,谁都可以把自己发起的授权地址发给别人,在回调时绑定,别人一授权,他的 GitHub 就绑到了发起者的账号上。
// 取出 state 之后出错时也返回发起授权的客户端,失败的结果同样要跳回它。授权进行中后台换了 OAuth App 时,GitHub 不认原来签发的
// 授权码,返回 ErrGitHubFailed。
func (g *GitHub) Callback(ctx context.Context, state, code string) (port.GitHubClient, string, error) {
request, err := g.logins.TakeRequest(ctx, hashSecret(state))
expired := errors.Is(err, port.ErrCodeNotFound)
if err != nil && !expired {
return 0, "", err
}
// 关闭了 GitHub 登录时重新发起也没有用,所以 state 无效时也不提示过期。
app, err := g.app(ctx)
if err != nil {
return request.Client, "", err
}
if expired {
return 0, "", ErrGitHubRequestExpired
}
githubID, err := g.oauth.UserID(ctx, app, code, request.CodeVerifier)
if err != nil {
return request.Client, "", fmt.Errorf("%w:%w", ErrGitHubFailed, err)
}
exchange, hash := newSecret()
if err := g.logins.SaveGrant(ctx, hash, port.GitHubGrant{Request: request, GitHubID: githubID}, githubGrantTTL); err != nil {
return request.Client, "", err
}
return request.Client, exchange, nil
}
// GitHub 没有授权(用户拒绝或者 GitHub 报错)时作废 state,返回发起授权的客户端。
func (g *GitHub) Cancel(ctx context.Context, state string) (port.GitHubClient, error) {
request, err := g.logins.TakeRequest(ctx, hashSecret(state))
if errors.Is(err, port.ErrCodeNotFound) {
return 0, ErrGitHubRequestExpired
}
if err != nil {
return 0, err
}
return request.Client, nil
}
type GitHubResult struct {
// 登录了已经绑定这个 GitHub 账号的账号时才有:发出请求的游客会话已经换成这个账号的新会话。
Session *Session
Member member.Member
Account Account
}
// current 和 currentToken 是发出请求的成员和它的成员 token,必须是发起授权的那个成员。
func (g *GitHub) Exchange(ctx context.Context, current member.Member, currentToken, code, nonce string) (GitHubResult, error) {
if _, err := g.app(ctx); err != nil {
return GitHubResult{}, err
}
grant, err := g.logins.TakeGrant(ctx, hashSecret(code))
if errors.Is(err, port.ErrCodeNotFound) {
return GitHubResult{}, ErrGitHubLoginExpired
}
if err != nil {
return GitHubResult{}, err
}
if grant.Request.MemberID != current.ID || subtle.ConstantTimeCompare(grant.Request.NonceHash, hashSecret(nonce)) != 1 {
return GitHubResult{}, ErrGitHubLoginExpired
}
switch grant.Request.Intent {
case port.GitHubBind:
return g.bind(ctx, current, grant.GitHubID)
case port.GitHubVerify:
return g.verify(ctx, current, currentToken, grant.GitHubID)
}
return g.login(ctx, current, currentToken, grant.GitHubID)
}
func (g *GitHub) login(ctx context.Context, current member.Member, currentToken string, githubID int64) (GitHubResult, error) {
a := g.accounts
if err := a.requireGuest(ctx, current.ID); err != nil {
return GitHubResult{}, err
}
account, err := a.members.AccountByGitHub(ctx, githubID)
if err == nil {
session, err := a.signIn(ctx, currentToken, account)
// 账号的密码恰好在这时被修改或重置:修改删除了其他会话,这次登录也作废,重新登录即可。
if errors.Is(err, port.ErrAccountPasswordChanged) {
return GitHubResult{}, ErrGitHubLoginExpired
}
if err != nil {
return GitHubResult{}, err
}
return GitHubResult{Session: &session, Member: session.Member, Account: accountOf(account)}, nil
}
if !errors.Is(err, port.ErrMemberNotFound) {
return GitHubResult{}, err
}
// 没有绑定过的 GitHub 账号登录就是注册:绑到发出请求的游客上,成员和它的会话都不变。
settings, err := a.site.Settings(ctx)
if err != nil {
return GitHubResult{}, err
}
if !settings.RegistrationOpen {
return GitHubResult{}, ErrRegistrationClosed
}
if err := a.members.BindGitHub(ctx, current.ID, githubID); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: Account{GitHub: true}}, nil
}
func (g *GitHub) bind(ctx context.Context, current member.Member, githubID int64) (GitHubResult, error) {
profile, err := g.accounts.members.AccountProfile(ctx, current.ID)
if err != nil {
return GitHubResult{}, err
}
if !profile.Registered() {
return GitHubResult{}, ErrNotRegistered
}
if err := g.accounts.members.BindGitHub(ctx, current.ID, githubID); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: Account{Username: profile.Username, Email: profile.Email, GitHub: true}}, nil
}
// 只确认身份,不修改账号:GitHub 账号要是这个账号绑定的。确认记在发出请求的会话上,Accounts.Register 补设用户名和密码时要用。
func (g *GitHub) verify(ctx context.Context, current member.Member, currentToken string, githubID int64) (GitHubResult, error) {
account, err := g.accounts.members.AccountByID(ctx, current.ID)
if err != nil {
return GitHubResult{}, err
}
if account.GitHubID != githubID {
return GitHubResult{}, ErrGitHubMismatch
}
if err := g.accounts.verifications.SaveVerification(ctx, hashSecret(currentToken), githubVerificationTTL); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: accountOf(account)}, nil
}
// 解绑之后要能用密码登录,所以账号必须设置过密码。
func (g *GitHub) Unbind(ctx context.Context, memberID, currentPassword string) error {
if _, err := g.app(ctx); err != nil {
return err
}
profile, err := g.accounts.members.AccountProfile(ctx, memberID)
if err != nil {
return err
}
switch {
case !profile.Registered():
return ErrNotRegistered
case profile.Username == "":
return port.ErrNoPassword
}
if _, err := g.accounts.checkCurrentPassword(ctx, memberID, profile.Username, currentPassword); err != nil {
return err
}
return g.accounts.members.UnbindGitHub(ctx, memberID)
}
+625
View File
@@ -0,0 +1,625 @@
package identity
import (
"context"
"errors"
"net/url"
"slices"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/track"
)
// 授权码就是 GitHub 用户 ID 的名字:code-42 换到 42,不认识的授权码换取失败。GitHub 上有 client-1 和 client-2 两个 OAuth App,
// Client Secret 对不上时换取失败。
type fakeOAuth struct {
users map[string]int64
secrets map[string]string
calls atomic.Int32
}
func (f *fakeOAuth) AuthorizeURL(app port.GitHubOAuthApp, state, _ string) string {
return "https://github.test/login/oauth/authorize?" + url.Values{"client_id": {app.ClientID}, "state": {state}}.Encode()
}
func (f *fakeOAuth) UserID(_ context.Context, app port.GitHubOAuthApp, code, _ string) (int64, error) {
f.calls.Add(1)
if secret, ok := f.secrets[app.ClientID]; !ok || secret != app.ClientSecret {
return 0, errors.New("incorrect_client_credentials")
}
id, ok := f.users[code]
if !ok {
return 0, errors.New("bad_verification_code")
}
return id, nil
}
type githubFixture struct {
g *GitHub
a *Accounts
s *Service
store *porttest.Store
q *fakeQuota
oauth *fakeOAuth
revoked func() [][]byte
}
func newGitHub(t *testing.T) githubFixture {
t.Helper()
a, s, store, q, revoked := newAccounts(t)
oauth := &fakeOAuth{users: map[string]int64{"code-42": 42, "code-7": 7}, secrets: map[string]string{"client-1": "secret-1", "client-2": "secret-2"}}
g := NewGitHub(a, store, oauth, porttest.NewCodes())
if err := g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}); err != nil {
t.Fatal(err)
}
return githubFixture{g: g, a: a, s: s, store: store, q: q, oauth: oauth, revoked: revoked}
}
func clientIDOf(t *testing.T, authorizeURL string) string {
t.Helper()
u, err := url.Parse(authorizeURL)
if err != nil {
t.Fatal(err)
}
return u.Query().Get("client_id")
}
func stateOf(t *testing.T, authorizeURL string) string {
t.Helper()
u, err := url.Parse(authorizeURL)
if err != nil {
t.Fatal(err)
}
return u.Query().Get("state")
}
// 走完授权和回调,返回兑换码。
func (f githubFixture) authorize(t *testing.T, s Session, intent port.GitHubIntent, nonce, code string) string {
t.Helper()
// 绑定要输入当前密码,测试中的账号都用这个密码注册。
authorizeURL, err := f.g.Authorize(t.Context(), s.Member.ID, clientIP, intent, port.GitHubWeb, nonce, "correct horse")
if err != nil {
t.Fatal(err)
}
_, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), code)
if err != nil {
t.Fatal(err)
}
return exchange
}
func (f githubFixture) flow(t *testing.T, s Session, intent port.GitHubIntent, code string) (GitHubResult, error) {
t.Helper()
exchange := f.authorize(t, s, intent, "nonce-"+s.MemberToken, code)
return f.g.Exchange(t.Context(), s.Member, s.MemberToken, exchange, "nonce-"+s.MemberToken)
}
func TestGitHubRegistersThenLogsIn(t *testing.T) {
f := newGitHub(t)
first := createMember(t, f.s)
result, err := f.flow(t, first, port.GitHubLogin, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session != nil || result.Member != first.Member || result.Account != (Account{GitHub: true}) {
t.Errorf("首次用 GitHub 登录 = %#v,期望注册:成员和会话都不变,账号只绑定了 GitHub", result)
}
if _, ok := tokenOwner(t, f.store, first.MemberToken); !ok {
t.Error("注册不更换会话")
}
if lists, err := f.store.MemberPlaylists(t.Context(), first.Member.ID); err != nil || len(lists) != 1 || !lists[0].Favorites {
t.Errorf("注册之后的歌单 = %#v, %v,期望一个“我喜欢”", lists, err)
}
guest := createMember(t, f.s)
f.revoked()
result, err = f.flow(t, guest, port.GitHubLogin, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session == nil || result.Session.Member != first.Member || result.Member != first.Member || result.Account != (Account{GitHub: true}) {
t.Fatalf("用已绑定的 GitHub 登录 = %#v,期望登录第一次注册的账号", result)
}
if m, ok := tokenOwner(t, f.store, result.Session.MemberToken); !ok || m != first.Member {
t.Error("登录应签发账号的新会话")
}
if _, ok := tokenOwner(t, f.store, guest.MemberToken); ok {
t.Error("登录之后游客原来的 token 应失效")
}
if got := f.revoked(); !sameHashes(got, guest.MemberToken) {
t.Errorf("广播的被删除的会话 = %x,期望游客原来的会话", got)
}
}
func TestGitHubLoginMovesGuestListensIntoAccount(t *testing.T) {
f := newGitHub(t)
owner := createMember(t, f.s)
if _, err := f.flow(t, owner, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
guest := createMember(t, f.s)
heard := time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
sunny := port.Listen{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天"}, HeardAt: heard}
rain := port.Listen{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香"}, HeardAt: heard.Add(time.Minute)}
if err := f.store.RecordListen(t.Context(), []string{owner.Member.ID}, sunny); err != nil {
t.Fatal(err)
}
if err := f.store.RecordListen(t.Context(), []string{guest.Member.ID}, rain); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if listens, err := f.store.MemberListens(t.Context(), owner.Member.ID); err != nil || !slices.Equal(listens, []port.Listen{rain, sunny}) {
t.Errorf("用 GitHub 登录之后账号的历史 = %#v, %v,期望并入游客听过的《七里香》", listens, err)
}
if listens, err := f.store.MemberListens(t.Context(), guest.Member.ID); err != nil || len(listens) != 0 {
t.Errorf("用 GitHub 登录之后游客的历史 = %#v, %v,期望移走", listens, err)
}
}
func TestGitHubRegistrationClosed(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if err := f.store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, alice, port.GitHubBind, "code-7"); err != nil {
t.Errorf("关闭注册时已有账号绑定 GitHub = %v,期望照常", err)
}
guest := createMember(t, f.s)
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); !errors.Is(err, ErrRegistrationClosed) {
t.Errorf("关闭注册时首次用 GitHub 登录 = %v,期望 ErrRegistrationClosed", err)
}
if _, registered, _ := f.a.Account(t.Context(), guest.Member.ID); registered {
t.Error("关闭注册时游客不应变成账号")
}
result, err := f.flow(t, guest, port.GitHubLogin, "code-7")
if err != nil || result.Session == nil || result.Session.Member != alice.Member {
t.Errorf("关闭注册时用已绑定的 GitHub 登录 = %#v, %v,期望登录 alice", result, err)
}
}
func TestGitHubBind(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
guest := createMember(t, f.s)
result, err := f.flow(t, alice, port.GitHubBind, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session != nil || result.Member != alice.Member || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("绑定 = %#v,期望 alice 绑定了 GitHub,会话不变", result)
}
if _, err := f.flow(t, bob, port.GitHubBind, "code-42"); !errors.Is(err, port.ErrGitHubTaken) {
t.Errorf("绑定已经属于别人的 GitHub = %v,期望 ErrGitHubTaken", err)
}
for _, tc := range []struct {
name string
session Session
intent port.GitHubIntent
want error
}{
{"已经绑定了 GitHub 的账号再绑定", alice, port.GitHubBind, port.ErrGitHubAlreadyBound},
{"游客绑定", guest, port.GitHubBind, ErrNotRegistered},
{"已经登录了账号的设备用 GitHub 登录", bob, port.GitHubLogin, ErrAlreadyLoggedIn},
} {
if _, err := f.g.Authorize(t.Context(), tc.session.Member.ID, clientIP, tc.intent, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, tc.want) {
t.Errorf("%s = %v,期望 %v", tc.name, err, tc.want)
}
}
result, err = f.flow(t, guest, port.GitHubLogin, "code-42")
if err != nil || result.Session == nil || result.Session.Member != alice.Member || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("绑定之后用 GitHub 登录 = %#v, %v,期望登录 alice", result, err)
}
}
// 兑换码只能由发起授权的那台设备、带着它保存的 nonce 兑换:别人把自己发起的授权发给你,你授权之后得到的兑换码对他也没用。
func TestGitHubExchangeChecksNonceAndMember(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
mallory := registerAccount(t, f.a, f.s, "mallory", "correct horse")
exchange := f.authorize(t, mallory, port.GitHubBind, "mallory-nonce", "code-42")
if _, err := f.g.Exchange(t.Context(), alice.Member, alice.MemberToken, exchange, "alice-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("别的设备兑换 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, exchange, "mallory-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("兑换失败之后再兑换 = %v,期望兑换码已经作废", err)
}
exchange = f.authorize(t, mallory, port.GitHubBind, "mallory-nonce", "code-42")
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, exchange, "guessed-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("nonce 不对 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, "unknown", "mallory-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("不存在的兑换码 = %v,期望 ErrGitHubLoginExpired", err)
}
if p, _ := f.store.AccountProfile(t.Context(), mallory.Member.ID); p.GitHub {
t.Error("兑换失败时不应绑定")
}
guest := createMember(t, f.s)
exchange = f.authorize(t, guest, port.GitHubLogin, "nonce", "code-42")
if _, err := f.a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "guest", "correct horse"); err != nil {
t.Fatal(err)
}
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrAlreadyLoggedIn) {
t.Errorf("发起登录之后注册了账号再兑换 = %v,期望 ErrAlreadyLoggedIn", err)
}
}
func TestGitHubCallback(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
f.q.taken = nil
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
if want := []string{"github:ip:" + clientIP}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
state := stateOf(t, authorizeURL)
if len(state) != 43 {
t.Errorf("state = %q,期望 256 位随机值的 base64url", state)
}
if _, _, err := f.g.Callback(t.Context(), state, "unknown-code"); !errors.Is(err, ErrGitHubFailed) {
t.Errorf("GitHub 换取失败 = %v,期望 ErrGitHubFailed", err)
}
calls := f.oauth.calls.Load()
if _, _, err := f.g.Callback(t.Context(), state, "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("同一个 state 再回调一次 = %v,期望 ErrGitHubRequestExpired", err)
}
if _, _, err := f.g.Callback(t.Context(), "forged", "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("不存在的 state = %v,期望 ErrGitHubRequestExpired", err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("state 无效时不应请求 GitHub")
}
f.q.exhausted["github:ip:"+clientIP] = true
if _, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", ""); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时发起授权 = %v,期望 ErrTooManyAttempts", err)
}
}
// 发起授权的客户端记在 state 对应的记录里:回调成功、换取失败和 GitHub 没有授权时,都按记录跳回发起授权的客户端。
func TestGitHubCallbackReturnsClient(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
start := func(client port.GitHubClient) string {
t.Helper()
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, client, "nonce", "")
if err != nil {
t.Fatal(err)
}
return stateOf(t, authorizeURL)
}
for _, client := range []port.GitHubClient{port.GitHubWeb, port.GitHubApp} {
if got, exchange, err := f.g.Callback(t.Context(), start(client), "code-42"); err != nil || got != client || exchange == "" {
t.Errorf("客户端 %v 发起的授权回调 = %v, %q, %v,期望跳回发起授权的客户端", client, got, exchange, err)
}
if got, _, err := f.g.Callback(t.Context(), start(client), "unknown-code"); !errors.Is(err, ErrGitHubFailed) || got != client {
t.Errorf("客户端 %v 发起的授权换取失败 = %v, %v,期望 ErrGitHubFailed 并跳回发起授权的客户端", client, got, err)
}
state := start(client)
if got, err := f.g.Cancel(t.Context(), state); err != nil || got != client {
t.Errorf("客户端 %v 发起的授权没有得到授权 = %v, %v,期望跳回发起授权的客户端", client, got, err)
}
calls := f.oauth.calls.Load()
if _, _, err := f.g.Callback(t.Context(), state, "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("没有得到授权之后用同一个 state 回调 = %v,期望 state 已经作废", err)
}
if _, err := f.g.Cancel(t.Context(), state); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("没有得到授权之后再取消同一个 state = %v,期望 ErrGitHubRequestExpired", err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("state 已经作废时不应请求 GitHub")
}
}
if _, err := f.g.Cancel(t.Context(), "forged"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("不存在的 state 没有得到授权 = %v,期望 ErrGitHubRequestExpired", err)
}
}
// state 10 分钟内有效,兑换码 1 分钟内有效。
func TestGitHubCodesExpire(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
time.Sleep(10*time.Minute + time.Second)
if _, _, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("10 分钟之后回调 = %v,期望 ErrGitHubRequestExpired", err)
}
authorizeURL, err = f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
time.Sleep(9 * time.Minute)
_, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42")
if err != nil {
t.Fatalf("9 分钟之后回调 = %v,期望 state 仍然有效", err)
}
time.Sleep(time.Minute + time.Second)
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("1 分钟之后兑换 = %v,期望 ErrGitHubLoginExpired", err)
}
})
}
// 只凭会话不能更换登录凭证:绑定 GitHub 要输入当前密码,额度与修改密码共用。
func TestGitHubBindRequiresCurrentPassword(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
f.q.taken = nil
for _, password := range []string{"", "wrong horse"} {
if _, err := f.g.Authorize(t.Context(), alice.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", password); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("当前密码为 %q 时发起绑定 = %v,期望 ErrWrongCurrentPassword", password, err)
}
}
if want := []string{"password:member:" + alice.Member.ID, "password:member:" + alice.Member.ID}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
if result, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("输入正确的当前密码之后绑定 = %#v, %v", result, err)
}
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
f.q.exhausted["password:member:"+bob.Member.ID] = true
if _, err := f.g.Authorize(t.Context(), bob.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时发起绑定 = %v,期望 ErrTooManyAttempts", err)
}
}
func TestGitHubUnbind(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if err := f.g.Unbind(t.Context(), gh.Member.ID, "correct horse"); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-42"); err != nil {
t.Fatal(err)
}
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); err != nil {
t.Fatal(err)
}
f.q.taken = nil
for _, password := range []string{"", "wrong horse"} {
if err := f.g.Unbind(t.Context(), gh.Member.ID, password); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("当前密码为 %q 时解绑 = %v,期望 ErrWrongCurrentPassword", password, err)
}
}
if want := []string{"password:member:" + gh.Member.ID, "password:member:" + gh.Member.ID}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
if account, _, _ := f.a.Account(t.Context(), gh.Member.ID); !account.GitHub {
t.Error("当前密码不对时不应解绑")
}
if err := f.g.Unbind(t.Context(), gh.Member.ID, "correct horse"); err != nil {
t.Fatal(err)
}
if account, ok, err := f.a.Account(t.Context(), gh.Member.ID); err != nil || !ok || account != (Account{Username: "octocat"}) {
t.Errorf("解绑之后 Account = %#v, %v, %v", account, ok, err)
}
if err := f.g.Unbind(t.Context(), createMember(t, f.s).Member.ID, "correct horse"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客解绑 = %v,期望 ErrNotRegistered", err)
}
}
// 拿到账号一个会话的人(比如借用过的设备)不知道密码,就不能把账号的 GitHub 换成自己的:本人修改密码或后台重置之后,他无法再用 GitHub 登录回来。
func TestGitHubCannotBeSwappedWithSessionAlone(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
borrowed := createMember(t, f.s)
stolen, _, _, err := f.a.Login(t.Context(), borrowed.Member, borrowed.MemberToken, clientIP, "alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
if err := f.g.Unbind(t.Context(), stolen.Member.ID, "guessed horse"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("不知道密码时解绑 = %v,期望 ErrWrongCurrentPassword", err)
}
if _, err := f.g.Authorize(t.Context(), bob.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", "guessed horse"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("不知道密码时给没有绑定 GitHub 的账号发起绑定 = %v,期望 ErrWrongCurrentPassword", err)
}
if err := f.a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); err != nil {
t.Fatal(err)
}
if _, err := f.a.ResetPassword(t.Context(), "alice"); err != nil {
t.Fatal(err)
}
attacker := createMember(t, f.s)
result, err := f.flow(t, attacker, port.GitHubLogin, "code-7")
if err != nil || result.Session != nil || result.Member != attacker.Member {
t.Errorf("用自己的 GitHub 登录 = %#v, %v,期望只注册了新账号,进不了 alice", result, err)
}
if account, err := f.store.AccountByGitHub(t.Context(), 42); err != nil || account.Member != alice.Member {
t.Errorf("GitHub 42 属于 %#v, %v,期望仍是 alice", account.Member, err)
}
}
// 只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的 GitHub 确认身份。
func TestGitHubVerify(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
for _, tc := range []struct {
name string
session Session
want error
}{
{"设置了密码的账号", alice, port.ErrAlreadyRegistered},
{"游客", createMember(t, f.s), ErrNotRegistered},
} {
if _, err := f.g.Authorize(t.Context(), tc.session.Member.ID, clientIP, port.GitHubVerify, port.GitHubWeb, "nonce", ""); !errors.Is(err, tc.want) {
t.Errorf("%s发起确认 = %v,期望 %v", tc.name, err, tc.want)
}
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-7"); !errors.Is(err, ErrGitHubMismatch) {
t.Errorf("用别的 GitHub 确认 = %v,期望 ErrGitHubMismatch", err)
}
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("用别的 GitHub 确认之后补设 = %v,期望 ErrGitHubUnverified", err)
}
result, err := f.flow(t, gh, port.GitHubVerify, "code-42")
if err != nil || result.Session != nil || result.Member != gh.Member || result.Account != (Account{GitHub: true}) {
t.Fatalf("用绑定的 GitHub 确认 = %#v, %v,期望成员和会话都不变", result, err)
}
if _, ok := tokenOwner(t, f.store, gh.MemberToken); !ok {
t.Error("确认不更换会话")
}
if account, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); err != nil || account != (Account{Username: "octocat", GitHub: true}) {
t.Errorf("确认之后补设 = %#v, %v", account, err)
}
}
// 用 GitHub 确认身份之后 10 分钟内要补设用户名和密码。
func TestGitHubVerificationExpires(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-42"); err != nil {
t.Fatal(err)
}
time.Sleep(10*time.Minute + time.Second)
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("确认 10 分钟之后补设 = %v,期望 ErrGitHubUnverified", err)
}
})
}
// 读到账号之后、签发会话之前,密码被修改或重置:修改时删除了其他会话,这次登录不能留下新会话。
func TestGitHubLoginRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
node := porttest.NewRedis().Node("a")
changing := NewAccounts(passwordChangedMembers{f.store}, f.store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), newGateway(f.store, node, node))
f.g = NewGitHub(changing, f.store, f.oauth, porttest.NewCodes())
guest := createMember(t, f.s)
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("密码在登录期间被修改 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, ok := tokenOwner(t, f.store, guest.MemberToken); !ok {
t.Error("登录失败时游客的 token 应仍然有效")
}
}
// 后台保存的 OAuth App:读取只给出 Client ID,授权和换取令牌都用最近一次保存的,清除之后就关闭了 GitHub 登录。
func TestGitHubOAuthApp(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
if id, err := f.g.ClientID(t.Context()); err != nil || id != "client-1" {
t.Errorf("ClientID = %q, %v,期望 client-1", id, err)
}
if err := f.g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-2", ClientSecret: "secret-2"}); err != nil {
t.Fatal(err)
}
if id, err := f.g.ClientID(t.Context()); err != nil || id != "client-2" {
t.Errorf("换成 client-2 之后 ClientID = %q, %v", id, err)
}
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
if got := clientIDOf(t, authorizeURL); got != "client-2" {
t.Errorf("授权地址的 client_id = %q,期望新保存的 client-2", got)
}
if _, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42"); err != nil || exchange == "" {
t.Errorf("用新的 Client Secret 换取 = %q, %v", exchange, err)
}
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Fatal(err)
}
if id, err := f.g.ClientID(t.Context()); err != nil || id != "" {
t.Errorf("清除之后 ClientID = %q, %v,期望空字符串", id, err)
}
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Errorf("没有配置时再清除 = %v,期望什么也不做", err)
}
}
// 没有配置 OAuth App 时,发起授权、兑换和解绑都返回 ErrGitHubDisabled。授权进行中关闭了 GitHub 登录,回调也失败,
// 照样跳回发起授权的客户端;重新开启之后恢复。
func TestGitHubDisabled(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
guest := createMember(t, f.s)
pending, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubApp, "nonce", "")
if err != nil {
t.Fatal(err)
}
exchange := f.authorize(t, guest, port.GitHubLogin, "nonce", "code-7")
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Fatal(err)
}
f.q.taken = nil
for _, intent := range []port.GitHubIntent{port.GitHubLogin, port.GitHubBind, port.GitHubVerify} {
if _, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, intent, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后发起 %v = %v,期望 ErrGitHubDisabled", intent, err)
}
}
if len(f.q.taken) != 0 {
t.Errorf("关闭之后发起授权取了额度 %v", f.q.taken)
}
calls := f.oauth.calls.Load()
if client, _, err := f.g.Callback(t.Context(), stateOf(t, pending), "code-42"); !errors.Is(err, ErrGitHubDisabled) || client != port.GitHubApp {
t.Errorf("授权进行中关闭,回调 = %v, %v,期望 ErrGitHubDisabled 并跳回 App", client, err)
}
if client, _, err := f.g.Callback(t.Context(), "forged", "code-42"); !errors.Is(err, ErrGitHubDisabled) || client != 0 {
t.Errorf("关闭之后不存在的 state 回调 = %v, %v,期望 ErrGitHubDisabled:重新发起也没有用,不提示过期", client, err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("关闭之后不应请求 GitHub")
}
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后兑换 = %v,期望 ErrGitHubDisabled", err)
}
if err := f.g.Unbind(t.Context(), alice.Member.ID, "correct horse"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后解绑 = %v,期望 ErrGitHubDisabled", err)
}
if p, err := f.store.AccountProfile(t.Context(), alice.Member.ID); err != nil || !p.GitHub {
t.Errorf("关闭之后账号的绑定 = %#v, %v,期望不变", p, err)
}
if p, err := f.store.AccountProfile(t.Context(), guest.Member.ID); err != nil || p.Registered() {
t.Errorf("关闭之后游客 = %#v, %v,期望兑换没有注册账号", p, err)
}
if err := f.g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}); err != nil {
t.Fatal(err)
}
if result, err := f.flow(t, guest, port.GitHubLogin, "code-7"); err != nil || result.Account != (Account{GitHub: true}) {
t.Errorf("重新开启之后用 GitHub 注册 = %#v, %v", result, err)
}
}
+37 -11
View File
@@ -3,6 +3,8 @@ package identity
import (
"context"
"errors"
"regexp"
"strings"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/realtime"
@@ -15,8 +17,11 @@ var (
ErrInvalidTicket = errors.New("票据无效")
ErrWrongPassword = errors.New("房间密码错误")
ErrBotSession = errors.New("API Key 只能用于 HTTP 接口")
ErrInvalidEmail = errors.New("email:格式不正确,应形如 name@example.com")
)
var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
type Service struct {
members port.Members
rooms port.RoomDirectory
@@ -55,17 +60,21 @@ func (s *Service) Authenticate(ctx context.Context, memberToken string) (member.
type Profile struct {
Name *string
// 只用来计算头像哈希,不保存;空字符串表示清除头像。
// 用来计算头像哈希;已注册的账号同时保存它。空字符串表示清除。
Email *string
}
func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile) (member.Member, error) {
var avatar *string
var avatar, email *string
if p.Email != nil {
hash := avatarHash(*p.Email)
avatar = &hash
trimmed := strings.TrimSpace(*p.Email)
if trimmed != "" && !emailPattern.MatchString(trimmed) {
return member.Member{}, ErrInvalidEmail
}
hash := avatarHash(trimmed)
avatar, email = &hash, &trimmed
}
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar)
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar, email)
if err != nil {
return member.Member{}, err
}
@@ -75,7 +84,8 @@ func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile)
return m, nil
}
func (s *Service) IssueTicket(ctx context.Context, memberID, roomID, password string) (string, error) {
// memberToken 是发出请求的会话,票据属于这个会话:会话删除时票据随之失效。
func (s *Service) IssueTicket(ctx context.Context, memberID, memberToken, roomID, password string) (string, error) {
r, err := s.rooms.FindRoom(ctx, roomID)
if err != nil {
return "", err
@@ -90,17 +100,26 @@ 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
}
return ticket, nil
}
// 连接在网关中登记的凭证,与票据在存储中的哈希相同:网关定期拿它到存储中核对,不同的话仍然有效的连接也会被当作已撤销而断开。
func TicketCredential(ticket string) []byte {
return hashSecret(ticket)
}
func (s *Service) VerifyTicket(ctx context.Context, ticket string) (port.TicketHolder, error) {
if ticket == "" {
return port.TicketHolder{}, ErrInvalidTicket
@@ -128,12 +147,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, TicketCredential(ticket))
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}
}
+98 -5
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"regexp"
"testing"
"testing/synctest"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
@@ -27,7 +28,7 @@ func newService(t *testing.T) (*Service, *porttest.Store) {
// 没有调用 realtime.Start:这些测试不运行房间。
func newGateway(store *porttest.Store, node *porttest.Node, bus port.Bus) *realtime.Gateway {
_, gateway := realtime.New(store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
_, gateway := realtime.New(store, store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
return gateway
}
@@ -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, 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)
}
+90
View File
@@ -0,0 +1,90 @@
package identity
import (
"context"
"errors"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
var ErrInvalidPairingCode = errors.New("配对码不对或已经过期")
const (
pairingTTL = 5 * time.Minute
// 生成配对码只占 Redis 的空间:额度防止一个账号在短时间内灌入大量配对码。
pairingCreateBurst = 5
pairingCreateRefill = time.Minute
// 配对码有 32^8 ≈ 1.1e12 种,5 分钟就过期:按 IP 的额度与登录相同,足以让猜中的可能性忽略不计。
pairingRedeemBurst = 10
pairingRedeemRefill = time.Minute
// 新配对码与有效的配对码撞上的可能性约为万亿分之一,重试几次足够。
pairingAttempts = 3
)
type PairingCode struct {
Code string
ExpiresAt time.Time
}
// token 是发出请求的会话:配对码属于它,它被删除(退出登录、修改或重置密码)时配对码随之作废。
func (a *Accounts) CreatePairingCode(ctx context.Context, memberID, token string) (PairingCode, error) {
if _, registered, err := a.Account(ctx, memberID); err != nil {
return PairingCode{}, err
} else if !registered {
return PairingCode{}, ErrNotRegistered
}
if err := a.take(ctx, "pairing:member:"+memberID, pairingCreateBurst, pairingCreateRefill); err != nil {
return PairingCode{}, err
}
expires := time.Now().Add(pairingTTL)
p := port.Pairing{MemberID: memberID, SessionTokenHash: hashSecret(token)}
for range pairingAttempts {
code := newPairingCode()
created, err := a.pairings.CreatePairing(ctx, hashSecret(code), p, pairingTTL)
if err != nil {
return PairingCode{}, err
}
if created {
return PairingCode{Code: code, ExpiresAt: expires}, nil
}
}
return PairingCode{}, errors.New("生成的配对码都与有效的配对码重复")
}
// 兑换配对码就是登录:与 Login 相同,只有游客能兑换,成功后游客的这个会话随即失效。code 已经按协议校验过格式。
func (a *Accounts) RedeemPairingCode(ctx context.Context, current member.Member, currentToken, clientIP, code string) (Session, Account, error) {
if err := a.requireGuest(ctx, current.ID); err != nil {
return Session{}, Account{}, err
}
if err := a.take(ctx, "pairing:ip:"+quotaAddr(clientIP), pairingRedeemBurst, pairingRedeemRefill); err != nil {
return Session{}, Account{}, err
}
pairing, err := a.pairings.TakePairing(ctx, hashSecret(code))
if errors.Is(err, port.ErrCodeNotFound) {
return Session{}, Account{}, ErrInvalidPairingCode
}
if err != nil {
return Session{}, Account{}, err
}
// 先读密码哈希,再确认生成配对码的会话还在:修改或重置密码在此之前提交时,那个会话已经被删除;在此之后提交时,
// 签发会话会因为密码哈希变了而失败。
account, err := a.members.AccountByID(ctx, pairing.MemberID)
if err != nil {
return Session{}, Account{}, err
}
if _, err := a.members.MemberByToken(ctx, pairing.SessionTokenHash); errors.Is(err, port.ErrMemberNotFound) {
return Session{}, Account{}, ErrInvalidPairingCode
} else if err != nil {
return Session{}, Account{}, err
}
session, err := a.signIn(ctx, currentToken, account)
if errors.Is(err, port.ErrAccountPasswordChanged) {
return Session{}, Account{}, ErrInvalidPairingCode
}
if err != nil {
return Session{}, Account{}, err
}
return session, accountOf(account), nil
}
+237
View File
@@ -0,0 +1,237 @@
package identity
import (
"errors"
"regexp"
"slices"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/track"
)
func TestPairingCodeAlphabet(t *testing.T) {
format := regexp.MustCompile(`^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8}$`)
seen := map[rune]bool{}
codes := map[string]bool{}
for range 1000 {
code := newPairingCode()
if !format.MatchString(code) {
t.Fatalf("配对码 %q 应是 8 位,不含 0、O、1、I", code)
}
codes[code] = true
for _, r := range code {
seen[r] = true
}
}
if len(seen) != 32 {
t.Errorf("1000 个配对码只用到 %d 个字符,期望字母表中的 32 个都会出现", len(seen))
}
if len(codes) < 999 {
t.Errorf("1000 个配对码中只有 %d 个不同", len(codes))
}
}
func createPairingCode(t *testing.T, a *Accounts, s Session) string {
t.Helper()
code, err := a.CreatePairingCode(t.Context(), s.Member.ID, s.MemberToken)
if err != nil {
t.Fatal(err)
}
return code.Code
}
func TestPairDevice(t *testing.T) {
a, s, store, q, revoked := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
guestTicket := issueTicket(t, s, guest, createRoom(t, store, ""))
q.taken = nil
before := time.Now()
code, err := a.CreatePairingCode(t.Context(), alice.Member.ID, alice.MemberToken)
if err != nil {
t.Fatal(err)
}
if code.ExpiresAt.Before(before.Add(5*time.Minute-time.Second)) || code.ExpiresAt.After(time.Now().Add(5*time.Minute)) {
t.Errorf("配对码在 %v 过期,期望 5 分钟之后", code.ExpiresAt.Sub(before))
}
revoked()
session, account, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code.Code)
if err != nil {
t.Fatal(err)
}
if session.Member != alice.Member || account != (Account{Username: "alice"}) {
t.Errorf("兑换配对码 = %#v, %#v,期望登录 alice", session.Member, account)
}
if m, ok := tokenOwner(t, store, session.MemberToken); !ok || m != alice.Member {
t.Error("兑换配对码应签发账号的新会话")
}
if _, ok := tokenOwner(t, store, guest.MemberToken); ok {
t.Error("兑换之后游客原来的 token 应失效")
}
if got := revoked(); !sameHashes(got, guest.MemberToken) || ticketValid(t, s, guestTicket) {
t.Errorf("广播的被删除的会话 = %x,期望游客原来的会话", got)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
t.Error("兑换不影响生成配对码的会话")
}
if want := []string{"pairing:member:" + alice.Member.ID, "pairing:ip:" + clientIP}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
other := createMember(t, s)
if _, _, err := a.RedeemPairingCode(t.Context(), other.Member, other.MemberToken, clientIP, code.Code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("同一个配对码再兑换一次 = %v,期望 ErrInvalidPairingCode", err)
}
if _, _, err := a.RedeemPairingCode(t.Context(), other.Member, other.MemberToken, clientIP, "ABCD2345"); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("不存在的配对码 = %v,期望 ErrInvalidPairingCode", err)
}
if _, ok := tokenOwner(t, store, other.MemberToken); !ok {
t.Error("兑换失败时游客的 token 应仍然有效")
}
}
func TestPairingMovesGuestListensIntoAccount(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
heard := time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
sunny := port.Listen{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天"}, HeardAt: heard}
rain := port.Listen{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香"}, HeardAt: heard.Add(time.Minute)}
if err := store.RecordListen(t.Context(), []string{alice.Member.ID}, sunny); err != nil {
t.Fatal(err)
}
if err := store.RecordListen(t.Context(), []string{guest.Member.ID}, rain); err != nil {
t.Fatal(err)
}
code := createPairingCode(t, a, alice)
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code); err != nil {
t.Fatal(err)
}
if listens, err := store.MemberListens(t.Context(), alice.Member.ID); err != nil || !slices.Equal(listens, []port.Listen{rain, sunny}) {
t.Errorf("兑换配对码之后账号的历史 = %#v, %v,期望并入游客听过的《七里香》", listens, err)
}
if listens, err := store.MemberListens(t.Context(), guest.Member.ID); err != nil || len(listens) != 0 {
t.Errorf("兑换配对码之后游客的历史 = %#v, %v,期望移走", listens, err)
}
}
func TestPairingRejected(t *testing.T) {
a, s, _, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
bob := registerAccount(t, a, s, "bob", "correct horse")
guest := createMember(t, s)
q.taken = nil
if _, err := a.CreatePairingCode(t.Context(), guest.Member.ID, guest.MemberToken); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客生成配对码 = %v,期望 ErrNotRegistered", err)
}
if len(q.taken) != 0 {
t.Errorf("游客不应消耗额度:%v", q.taken)
}
code := createPairingCode(t, a, alice)
if _, _, err := a.RedeemPairingCode(t.Context(), bob.Member, bob.MemberToken, clientIP, code); !errors.Is(err, ErrAlreadyLoggedIn) {
t.Errorf("已经登录了账号的设备兑换 = %v,期望 ErrAlreadyLoggedIn", err)
}
q.exhausted["pairing:ip:"+clientIP] = true
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时兑换 = %v,期望 ErrTooManyAttempts", err)
}
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, "198.51.100.1", code); err != nil {
t.Errorf("被拒绝的兑换不应用掉配对码:%v", err)
}
q.exhausted["pairing:member:"+bob.Member.ID] = true
if _, err := a.CreatePairingCode(t.Context(), bob.Member.ID, bob.MemberToken); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时生成配对码 = %v,期望 ErrTooManyAttempts", err)
}
}
// 让其他设备退出的办法是修改密码或者重置密码:此前生成的配对码要随生成它的会话一起作废,否则拿到配对码的人还能登录进来。
func TestPairingCodeDiesWithItsSession(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
login := func() Session {
t.Helper()
guest := createMember(t, s)
session, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
return session
}
redeem := func(code string) error {
t.Helper()
guest := createMember(t, s)
_, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code)
return err
}
other := login()
code := createPairingCode(t, a, other)
if err := a.Logout(t.Context(), other.MemberToken); err != nil {
t.Fatal(err)
}
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("生成配对码的会话退出之后兑换 = %v,期望 ErrInvalidPairingCode", err)
}
other = login()
code = createPairingCode(t, a, other)
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse"); err != nil {
t.Fatal(err)
}
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("另一台设备修改密码之后兑换 = %v,期望 ErrInvalidPairingCode", err)
}
code = createPairingCode(t, a, alice)
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse"); err != nil {
t.Fatal(err)
}
if err := redeem(code); err != nil {
t.Errorf("生成配对码的会话自己修改了密码,它仍然有效,兑换 = %v", err)
}
code = createPairingCode(t, a, alice)
if _, err := a.ResetPassword(t.Context(), "alice"); err != nil {
t.Fatal(err)
}
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("重置密码之后兑换 = %v,期望 ErrInvalidPairingCode", err)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); ok {
t.Fatal("重置密码应删除账号的全部会话")
}
}
func TestPairGitHubOnlyAccount(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
gh := githubAccount(t, s, store, 42)
code := createPairingCode(t, a, gh)
guest := createMember(t, s)
session, account, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code)
if err != nil || session.Member != gh.Member || account != (Account{GitHub: true}) {
t.Errorf("兑换只绑定了 GitHub 的账号的配对码 = %#v, %#v, %v", session.Member, account, err)
}
}
// 读到账号的密码哈希之后、签发会话之前,密码被修改或重置:修改时删除了其他会话,这次兑换不能留下新会话。
func TestRedeemRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
codes := porttest.NewCodes()
node := porttest.NewRedis().Node("a")
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, codes, codes, porttest.NewDevices(), newGateway(store, node, node))
code, err := changing.CreatePairingCode(t.Context(), alice.Member.ID, alice.MemberToken)
if err != nil {
t.Fatal(err)
}
guest := createMember(t, s)
if _, _, err := changing.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code.Code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("密码在兑换期间被修改 = %v,期望 ErrInvalidPairingCode", err)
}
if _, ok := tokenOwner(t, store, guest.MemberToken); !ok {
t.Error("兑换失败时游客的 token 应仍然有效")
}
}
Loaded 100 of 340 files, more files were not shown because too many files have changed in this diff. Show more