feat: verify the QQ Music account with QQ Music
CI / go (push) Waiting to run
CI / web (push) Waiting to run
CI / images (Dockerfile, server, alisten) (push) Waiting for needed jobs
CI / images (web/Dockerfile, web, ) (push) Waiting for needed jobs
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Waiting for needed jobs
CI / helm-release (push) Waiting for needed jobs
CI / deploy (push) Waiting for needed jobs
CI / go (push) Waiting to run
CI / web (push) Waiting to run
CI / images (Dockerfile, server, alisten) (push) Waiting for needed jobs
CI / images (web/Dockerfile, web, ) (push) Waiting for needed jobs
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Waiting for needed jobs
CI / helm-release (push) Waiting for needed jobs
CI / deploy (push) Waiting for needed jobs
This commit is contained in:
1 parent
6f286c2eb2
commit
dab7e2f46b
7 files changed
+135
-7
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
@@ -0,0 +1 @@
|
||||
{"code":0,"req":{"code":301,"data":{}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"code":0,"req":{"code":0,"data":{}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"code":0,"req":{"code":0,"data":{"identity":{"vip":0},"svip":1}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"code":0,"req":{"code":0,"data":{"identity":{"vip":1},"svip":0}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"code":0,"req":{"code":0,"data":{"identity":{"vip":0},"svip":0}}}
|
||||
Reference in new issue
Block a user