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

This commit is contained in:
DiheChen committed 2026-11-11 00:31:38 +08:00
1 parent 6f286c2eb2
commit dab7e2f46b
7 files changed
+135 -7

No files matched your search

+52 -1
View File
@@ -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 {
+78 -6
View File
@@ -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 }
+1
View File
@@ -0,0 +1 @@
{"code":0,"req":{"code":301,"data":{}}}
@@ -0,0 +1 @@
{"code":0,"req":{"code":0,"data":{}}}
+1
View File
@@ -0,0 +1 @@
{"code":0,"req":{"code":0,"data":{"identity":{"vip":0},"svip":1}}}
+1
View File
@@ -0,0 +1 @@
{"code":0,"req":{"code":0,"data":{"identity":{"vip":1},"svip":0}}}
+1
View File
@@ -0,0 +1 @@
{"code":0,"req":{"code":0,"data":{"identity":{"vip":0},"svip":0}}}