From dab7e2f46be0cffcd36e4612874e3ab7a30cf020 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 00:31:38 +0800 Subject: [PATCH] feat: verify the QQ Music account with QQ Music --- internal/adapter/out/qq/account.go | 53 +++++++++++- internal/adapter/out/qq/account_test.go | 84 +++++++++++++++++-- .../out/qq/testdata/account-expired.json | 1 + .../out/qq/testdata/account-no-identity.json | 1 + .../adapter/out/qq/testdata/account-svip.json | 1 + .../adapter/out/qq/testdata/account-vip.json | 1 + internal/adapter/out/qq/testdata/account.json | 1 + 7 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 internal/adapter/out/qq/testdata/account-expired.json create mode 100644 internal/adapter/out/qq/testdata/account-no-identity.json create mode 100644 internal/adapter/out/qq/testdata/account-svip.json create mode 100644 internal/adapter/out/qq/testdata/account-vip.json create mode 100644 internal/adapter/out/qq/testdata/account.json diff --git a/internal/adapter/out/qq/account.go b/internal/adapter/out/qq/account.go index e480e9b..d800565 100644 --- a/internal/adapter/out/qq/account.go +++ b/internal/adapter/out/qq/account.go @@ -2,11 +2,34 @@ package qq import ( "context" + "encoding/json" + "errors" + "net/url" "strings" "github.com/dihemoe/alisten/internal/app/port" ) +type accountResponse struct { + Code *int `json:"code"` + Req struct { + Code *int `json:"code"` + Data struct { + Identity *struct { + VIP int `json:"vip"` + } `json:"identity"` + SVIP int `json:"svip"` + } `json:"data"` + } `json:"req"` +} + +func (r accountResponse) check() error { + if r.Code == nil || r.Req.Code == nil { + return errors.New("QQ 音乐账号响应的 code 或 req.code 缺失或为 null") + } + return nil +} + func (c *Client) CurrentAccount(ctx context.Context) (*port.SourceAccount, error) { cookie, err := c.cookie.get(ctx) if err != nil { @@ -16,7 +39,35 @@ func (c *Client) CurrentAccount(ctx context.Context) (*port.SourceAccount, error if id == "" { return nil, nil } - return &port.SourceAccount{ID: id}, nil + payload, err := json.Marshal(map[string]any{ + "comm": map[string]any{ + "ct": 24, "cv": 4747474, "platform": "yqq.json", "uin": id, + "g_tk": 5381, "g_tk_new_20200303": 5381, + "authst": qqAccountCookieValue(cookie, "qm_keyst"), + "format": "json", "inCharset": "utf8", "outCharset": "utf-8", "notice": 0, "needNewCode": 1, + }, + "req": map[string]any{"module": "VipLogin.VipLoginInter", "method": "vip_login_base", "param": map[string]any{}}, + }) + if err != nil { + return nil, err + } + var resp accountResponse + query := url.Values{"data": {string(payload)}} + if err := c.get(ctx, "qq", "account", musicuAPI, query, desktopHeader, &resp); err != nil { + return nil, err + } + // 非零业务码是 QQ 音乐认为登录无效,与网易云的个人资料为空同样处理:没有账号,不算查询失败。 + if *resp.Code != 0 || *resp.Req.Code != 0 { + return nil, nil + } + account := &port.SourceAccount{ID: id, Verified: true} + // identity 缺失时会员状态拿不准,留 nil 让界面显示未知。 + if resp.Req.Data.Identity != nil { + account.VIP = new(bool) + *account.VIP = resp.Req.Data.Identity.VIP != 0 || resp.Req.Data.SVIP != 0 + } + // QQ 音乐的网页接口对自有账号隐藏昵称,拿不到就不显示。 + return account, nil } func qqAccountID(cookie string) string { diff --git a/internal/adapter/out/qq/account_test.go b/internal/adapter/out/qq/account_test.go index f4128f5..d02979c 100644 --- a/internal/adapter/out/qq/account_test.go +++ b/internal/adapter/out/qq/account_test.go @@ -4,15 +4,39 @@ import ( "context" "errors" "net/http" + "net/http/httptest" + "net/url" "reflect" "testing" "github.com/dihemoe/alisten/internal/app/port" ) -func accountTestClient(cookie string, loadErr error) *Client { - load := func(context.Context) (string, error) { return cookie, loadErr } - return New(&http.Client{}, load, nil, (&observed{}).record, testLogger) +func accountTestClient(t *testing.T, cookie string, handler http.HandlerFunc) *Client { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/cgi-bin/musicu.fcg" { + t.Errorf("意外的请求 %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.Header.Get("Cookie"); got != cookie { + t.Errorf("账号接口的 Cookie = %q,期望已保存的凭据", got) + } + handler(w, r) + })) + t.Cleanup(server.Close) + target, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + return New( + &http.Client{Transport: redirect{target}}, + func(context.Context) (string, error) { return cookie, nil }, + nil, + (&observed{}).record, + testLogger, + ) } func TestCurrentAccount(t *testing.T) { @@ -37,7 +61,7 @@ func TestCurrentAccount(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got, err := accountTestClient(tc.cookie, nil).CurrentAccount(t.Context()) + got, err := accountTestClient(t, tc.cookie, fixture(t, "account.json")).CurrentAccount(t.Context()) if err != nil { t.Fatal(err) } @@ -47,7 +71,7 @@ func TestCurrentAccount(t *testing.T) { } return } - want := &port.SourceAccount{ID: tc.wantID} + want := &port.SourceAccount{ID: tc.wantID, VIP: new(bool), Verified: true} if !reflect.DeepEqual(got, want) { t.Fatalf("账号资料 = %+v,期望 %+v", got, want) } @@ -55,9 +79,57 @@ func TestCurrentAccount(t *testing.T) { } } +// 绿钻和豪华绿钻任一有效都算会员,identity 缺失时 VIP 留 nil 显示未知。 +func TestCurrentAccountVIP(t *testing.T) { + for _, tc := range []struct { + file string + wantVIP *bool + }{ + {"account-vip.json", boolPtr(true)}, + {"account-svip.json", boolPtr(true)}, + {"account.json", boolPtr(false)}, + {"account-no-identity.json", nil}, + } { + got, err := accountTestClient(t, "uin=o000123", fixture(t, tc.file)).CurrentAccount(t.Context()) + if err != nil { + t.Fatal(err) + } + if got == nil || got.Verified != true || !reflect.DeepEqual(got.VIP, tc.wantVIP) { + t.Errorf("%s 的 VIP = %v,期望 %v", tc.file, got.VIP, tc.wantVIP) + } + } +} + +// QQ 音乐认为登录无效时返回 nil 账号而不是错误,与网易云个人资料为空一致。 +func TestCurrentAccountExpired(t *testing.T) { + got, err := accountTestClient(t, "uin=o000123", fixture(t, "account-expired.json")).CurrentAccount(t.Context()) + if err != nil || got != nil { + t.Fatalf("登录失效时 = %+v, %v,期望 nil 账号且无错误", got, err) + } +} + +// 上游异常是查询失败,不能冒充没有账号。 +func TestCurrentAccountUpstreamError(t *testing.T) { + for name, handler := range map[string]http.HandlerFunc{ + "HTTP 502": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) }, + "响应 JSON 畸形": body(`{"code":0,`), + "业务码缺失": body(`{"code":0,"req":{"data":{}}}`), + } { + t.Run(name, func(t *testing.T) { + if _, err := accountTestClient(t, "uin=o000123", handler).CurrentAccount(t.Context()); err == nil { + t.Error("上游异常时没有返回错误") + } + }) + } +} + func TestCurrentAccountCookieError(t *testing.T) { want := errors.New("读取失败") - if _, err := accountTestClient("", want).CurrentAccount(t.Context()); !errors.Is(err, want) { + load := func(context.Context) (string, error) { return "", want } + c := New(&http.Client{}, load, nil, (&observed{}).record, testLogger) + if _, err := c.CurrentAccount(t.Context()); !errors.Is(err, want) { t.Fatalf("读取凭据的错误 = %v,期望 %v", err, want) } } + +func boolPtr(b bool) *bool { return &b } diff --git a/internal/adapter/out/qq/testdata/account-expired.json b/internal/adapter/out/qq/testdata/account-expired.json new file mode 100644 index 0000000..b9bc934 --- /dev/null +++ b/internal/adapter/out/qq/testdata/account-expired.json @@ -0,0 +1 @@ +{"code":0,"req":{"code":301,"data":{}}} diff --git a/internal/adapter/out/qq/testdata/account-no-identity.json b/internal/adapter/out/qq/testdata/account-no-identity.json new file mode 100644 index 0000000..d98e19c --- /dev/null +++ b/internal/adapter/out/qq/testdata/account-no-identity.json @@ -0,0 +1 @@ +{"code":0,"req":{"code":0,"data":{}}} diff --git a/internal/adapter/out/qq/testdata/account-svip.json b/internal/adapter/out/qq/testdata/account-svip.json new file mode 100644 index 0000000..21cc72f --- /dev/null +++ b/internal/adapter/out/qq/testdata/account-svip.json @@ -0,0 +1 @@ +{"code":0,"req":{"code":0,"data":{"identity":{"vip":0},"svip":1}}} diff --git a/internal/adapter/out/qq/testdata/account-vip.json b/internal/adapter/out/qq/testdata/account-vip.json new file mode 100644 index 0000000..827bd7a --- /dev/null +++ b/internal/adapter/out/qq/testdata/account-vip.json @@ -0,0 +1 @@ +{"code":0,"req":{"code":0,"data":{"identity":{"vip":1},"svip":0}}} diff --git a/internal/adapter/out/qq/testdata/account.json b/internal/adapter/out/qq/testdata/account.json new file mode 100644 index 0000000..876d8ea --- /dev/null +++ b/internal/adapter/out/qq/testdata/account.json @@ -0,0 +1 @@ +{"code":0,"req":{"code":0,"data":{"identity":{"vip":0},"svip":0}}}