From 3d949e0ef3c6bd8b5d076ceb012a7352ae474d61 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 21 Jul 2026 12:55:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(share):=20UC/=E5=A4=B8=E5=85=8B/123=20?= =?UTF-8?q?=E5=85=8D=E8=BD=AC=E5=AD=98=E7=9B=B4=E9=93=BE=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 免转存:直接用分享凭据换直链,不转存到个人账号,减少等待与空间占用。 quark_uc_share: - 新增 resolveShareDirectLink,用分享凭据 POST /file/download 换原始文件直链(=原画)。 - UC 匿名取链 + 魔法 Referer(下载地址+"\ ")绕过阿里云 checkplay 回调,无需账号; 夸克 share /file/download 需账号上下文,带主账号 Cookie 取链。 - 直链 URL 追加 #x-referer=raw 片段标记,供客户端代理(如 alist-tvbox)识别后 改用魔法 Referer;后端代理仍由 link.Header 生效。片段不发往 CDN。 - Link() 以 share-direct 为主、save+delete 兜底;fid_token 失效按 pid 重取重试。 123_share: - 匿名优先换链(www.123pan.cn,无 Bearer/无签名),失败回退账号;无需 123Pan 账号即可播放。 - 识别 5112 流量包不足,直接返回真实原因不再静默回退。 - 抽出 unwrap123DownloadLink 解包 helper 复用。 测试:quark_uc_share 缓存与 share-direct 路由测试;123_share 匿名优先与 5112 测试。 Co-Authored-By: Claude --- drivers/123_share/driver.go | 104 +++++++++-------------- drivers/123_share/driver_test.go | 53 ++++++++++++ drivers/123_share/util.go | 118 +++++++++++++++++++++++++- drivers/quark_uc_share/driver.go | 11 ++- drivers/quark_uc_share/driver_test.go | 55 ++++++++++++ drivers/quark_uc_share/util.go | 93 ++++++++++++++++++++ 6 files changed, 366 insertions(+), 68 deletions(-) create mode 100644 drivers/123_share/driver_test.go diff --git a/drivers/123_share/driver.go b/drivers/123_share/driver.go index c3c8275e5..e65371ac4 100644 --- a/drivers/123_share/driver.go +++ b/drivers/123_share/driver.go @@ -2,13 +2,11 @@ package _123Share import ( "context" - "encoding/base64" "errors" "fmt" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/op" "net/http" - "net/url" "sync" "time" @@ -71,7 +69,21 @@ func (d *Pan123Share) List(ctx context.Context, dir model.Obj, args model.ListAr }) } +// resolveAnonLink 匿名换链入口,声明为 var 以便单测替换(规避真实网络/op 依赖)。 +var resolveAnonLink = func(d *Pan123Share, f File, ip string) (*model.Link, error) { + return d.anonDownloadLink(f, ip) +} + func (d *Pan123Share) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + // 匿名优先:公开分享可免登录换直链,无需 123Pan 账号。 + if f, ok := file.(File); ok { + if link, err := resolveAnonLink(d, f, args.IP); err == nil { + return link, nil + } else if errors.Is(err, err123TrafficLimit) { + // 分享方流量耗尽,账号重试也无意义,直接返回真实原因。 + return nil, err + } + } count := op.GetDriverCount("123Pan") var lastErr error for i := 0; i < count; i++ { @@ -92,71 +104,33 @@ func (d *Pan123Share) link(ctx context.Context, file model.Obj, args model.LinkA } pan123 := storage.(*_123.Pan123) log.Infof("[%v] 获取123文件直链 %v %v %v", pan123.ID, file.GetName(), file.GetID(), file.GetSize()) - // TODO return link of file, required - if f, ok := file.(File); ok { - //var resp DownResp - var headers map[string]string - if !utils.IsLocalIPAddr(args.IP) { - headers = map[string]string{ - //"X-Real-IP": "1.1.1.1", - "X-Forwarded-For": args.IP, - } - } - data := base.Json{ - "driveId": "0", - "shareKey": d.ShareKey, - "SharePwd": d.SharePwd, - "etag": f.Etag, - "fileId": f.FileId, - "s3keyFlag": f.S3KeyFlag, - "FileName": f.FileName, - "size": f.Size, - } - resp, err := pan123.Request(DownloadInfo, http.MethodPost, func(req *resty.Request) { - req.SetBody(data).SetHeaders(headers) - }, nil) - if err != nil { - return nil, err - } - downloadUrl := utils.Json.Get(resp, "data", "DownloadURL").ToString() - ou, err := url.Parse(downloadUrl) - if err != nil { - return nil, err - } - u_ := ou.String() - nu := ou.Query().Get("params") - if nu != "" { - du, _ := base64.StdEncoding.DecodeString(nu) - u, err := url.Parse(string(du)) - if err != nil { - return nil, err - } - u_ = u.String() - } - - log.Debug("download url: ", u_) - res, err := base.NoRedirectClient.R().SetHeader("Referer", "https://yun.123pan.com/").Get(u_) - if err != nil { - return nil, err - } - log.Debug(res.String()) - exp := 15 * time.Minute - link := model.Link{ - Expiration: &exp, - URL: u_, - } - log.Debugln("res code: ", res.StatusCode()) - if res.StatusCode() == 302 { - link.URL = res.Header().Get("location") - } else if res.StatusCode() < 300 { - link.URL = utils.Json.Get(res.Body(), "data", "redirect_url").ToString() - } - link.Header = http.Header{ - "Referer": []string{fmt.Sprintf("%s://%s/", ou.Scheme, ou.Host)}, + f, ok := file.(File) + if !ok { + return nil, fmt.Errorf("can't convert obj") + } + var headers map[string]string + if !utils.IsLocalIPAddr(args.IP) { + headers = map[string]string{ + "X-Forwarded-For": args.IP, } - return &link, nil } - return nil, fmt.Errorf("can't convert obj") + data := base.Json{ + "driveId": "0", + "shareKey": d.ShareKey, + "SharePwd": d.SharePwd, + "etag": f.Etag, + "fileId": f.FileId, + "s3keyFlag": f.S3KeyFlag, + "FileName": f.FileName, + "size": f.Size, + } + resp, err := pan123.Request(DownloadInfo, http.MethodPost, func(req *resty.Request) { + req.SetBody(data).SetHeaders(headers) + }, nil) + if err != nil { + return nil, err + } + return unwrap123DownloadLink(utils.Json.Get(resp, "data", "DownloadURL").ToString()) } func (d *Pan123Share) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { diff --git a/drivers/123_share/driver_test.go b/drivers/123_share/driver_test.go new file mode 100644 index 000000000..4cc319dee --- /dev/null +++ b/drivers/123_share/driver_test.go @@ -0,0 +1,53 @@ +package _123Share + +import ( + "context" + "errors" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +// File 实现了 model.Obj,可直接作为 Link 入参。 + +func TestPan123ShareLink_AnonymousFirstReturnsAnonLink(t *testing.T) { + // 无需 123Pan 账号:匿名成功即返回,不走账号路径。 + origAnon := resolveAnonLink + anonCalls := 0 + resolveAnonLink = func(d *Pan123Share, f File, ip string) (*model.Link, error) { + anonCalls++ + return &model.Link{URL: "https://example.com/anon-direct"}, nil + } + t.Cleanup(func() { resolveAnonLink = origAnon }) + + d := &Pan123Share{} + file := File{FileName: "video.mp4"} + + link, err := d.Link(context.Background(), file, model.LinkArgs{}) + if err != nil { + t.Fatalf("link: %v", err) + } + if link == nil || link.URL != "https://example.com/anon-direct" { + t.Fatalf("expected anon direct link, got %+v", link) + } + if anonCalls != 1 { + t.Fatalf("expected anon resolver once, got %d", anonCalls) + } +} + +func TestPan123ShareLink_TrafficLimitShortCircuits(t *testing.T) { + // 5112 流量包不足:不回退账号,直接透传真实错误。 + origAnon := resolveAnonLink + resolveAnonLink = func(d *Pan123Share, f File, ip string) (*model.Link, error) { + return nil, err123TrafficLimit + } + t.Cleanup(func() { resolveAnonLink = origAnon }) + + d := &Pan123Share{} + file := File{FileName: "video.mp4"} + + _, err := d.Link(context.Background(), file, model.LinkArgs{}) + if !errors.Is(err, err123TrafficLimit) { + t.Fatalf("expected err123TrafficLimit, got %v", err) + } +} diff --git a/drivers/123_share/util.go b/drivers/123_share/util.go index 8b6ff8ebc..a5ac4e1ad 100644 --- a/drivers/123_share/util.go +++ b/drivers/123_share/util.go @@ -2,10 +2,9 @@ package _123Share import ( "context" + "encoding/base64" "errors" "fmt" - _123 "github.com/OpenListTeam/OpenList/v4/drivers/123" - "github.com/OpenListTeam/OpenList/v4/internal/op" "hash/crc32" "math" "math/rand" @@ -15,10 +14,14 @@ import ( "strings" "time" + _123 "github.com/OpenListTeam/OpenList/v4/drivers/123" "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/go-resty/resty/v2" jsoniter "github.com/json-iterator/go" + log "github.com/sirupsen/logrus" ) const ( @@ -29,8 +32,17 @@ const ( FileList = MainApi + "/share/get" DownloadInfo = MainApi + "/share/download/info" //AuthKeySalt = "8-8D$sL8gPjom7bk#cY" + + // 匿名(免登录)通道:www.123pan.cn 的分享接口对公开分享可直接换直链,无需 Bearer/auth-key。 + AnonOrigin = "https://www.123pan.cn" + AnonDownloadInfo = AnonOrigin + "/b/api/share/download/info" + AnonUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" ) +// err123TrafficLimit 分享方提取流量耗尽(API code 5112)。账号重试或旧驱动都无法解决, +// 调用方应直接返回该错误而非静默回退。 +var err123TrafficLimit = errors.New("123 分享流量包不足(5112)") + var idx = 0 func signPath(path string, os string, version string) (k string, v string) { @@ -95,6 +107,108 @@ func (d *Pan123Share) request(url string, method string, callback base.ReqCallba return body, nil } +// requestAnon 匿名请求:无 Authorization、无 auth-key 签名,仅带浏览器 UA + Referer。 +// 用于公开分享免登录换直链。返回原始响应体,由调用方检查 code。 +func (d *Pan123Share) requestAnon(targetUrl, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { + req := base.RestyClient.R() + req.SetHeaders(map[string]string{ + "user-agent": AnonUA, + "referer": AnonOrigin + "/", + "origin": AnonOrigin, + "accept": "application/json,text/plain,*/*", + "platform": "android", + "app-version": "43", + "content-type": "application/json;charset=UTF-8", + }) + if callback != nil { + callback(req) + } + if resp != nil { + req.SetResult(resp) + } + res, err := req.Execute(method, targetUrl) + if err != nil { + return nil, err + } + return res.Body(), nil +} + +// unwrap123DownloadLink 把 download/info 返回的 DownloadURL 解包成可播放直链: +// 处理 base64 params 重定向、302 location / data.redirect_url,并设 Referer;15 分钟有效。 +func unwrap123DownloadLink(downloadUrl string) (*model.Link, error) { + if downloadUrl == "" { + return nil, errors.New("empty 123 download url") + } + ou, err := url.Parse(downloadUrl) + if err != nil { + return nil, err + } + u_ := ou.String() + if nu := ou.Query().Get("params"); nu != "" { + du, e := base64.StdEncoding.DecodeString(nu) + if e != nil { + return nil, e + } + u, e := url.Parse(string(du)) + if e != nil { + return nil, e + } + u_ = u.String() + } + log.Debug("123 download url: ", u_) + res, err := base.NoRedirectClient.R().SetHeader("Referer", AnonOrigin+"/").Get(u_) + if err != nil { + return nil, err + } + log.Debug(res.String()) + exp := 15 * time.Minute + link := &model.Link{Expiration: &exp, URL: u_} + log.Debugln("123 res code: ", res.StatusCode()) + if res.StatusCode() == 302 { + link.URL = res.Header().Get("location") + } else if res.StatusCode() < 300 { + link.URL = utils.Json.Get(res.Body(), "data", "redirect_url").ToString() + } + link.Header = http.Header{ + "Referer": []string{fmt.Sprintf("%s://%s/", ou.Scheme, ou.Host)}, + } + return link, nil +} + +// anonDownloadLink 匿名换取 123 分享直链(无需 123Pan 账号)。 +// 返回 err123TrafficLimit 表示分享方流量耗尽,调用方不应再回退账号重试。 +func (d *Pan123Share) anonDownloadLink(f File, ip string) (*model.Link, error) { + headers := map[string]string{} + if !utils.IsLocalIPAddr(ip) { + headers["X-Forwarded-For"] = ip + } + body := base.Json{ + "driveId": "0", + "shareKey": d.ShareKey, + "SharePwd": d.SharePwd, + "etag": f.Etag, + "fileId": f.FileId, + "s3keyFlag": f.S3KeyFlag, + "FileName": f.FileName, + "size": f.Size, + } + respBody, err := d.requestAnon(AnonDownloadInfo, http.MethodPost, func(req *resty.Request) { + req.SetBody(body).SetHeaders(headers) + }, nil) + if err != nil { + return nil, err + } + code := utils.Json.Get(respBody, "code").ToInt() + message := jsoniter.Get(respBody, "message").ToString() + if code == 5112 || strings.Contains(message, "流量包不足") || strings.Contains(message, "提取流量不足") { + return nil, err123TrafficLimit + } + if code != 0 { + return nil, errors.New(message) + } + return unwrap123DownloadLink(utils.Json.Get(respBody, "data", "DownloadURL").ToString()) +} + func (d *Pan123Share) getFiles(ctx context.Context, parentId string) ([]File, error) { page := 1 res := make([]File, 0) diff --git a/drivers/quark_uc_share/driver.go b/drivers/quark_uc_share/driver.go index 5e4a62218..8f2b9b35e 100755 --- a/drivers/quark_uc_share/driver.go +++ b/drivers/quark_uc_share/driver.go @@ -99,7 +99,16 @@ func (d *QuarkUCShare) Link(ctx context.Context, file model.Obj, args model.Link return link, nil } - link, err := resolveQuarkUCShareLink(ctx, d, file, args) + // 免转存(share-direct)为主:用分享凭据直接换直链,得到原始文件=原画, + // 且不转存、不占空间、无需账号(SVIP/非 SVIP 一视同仁)。share-direct 已由魔法 Referer + // 绕过 checkplay,UC 验证可行,夸克同后端同手法。失败再回退 save+delete(需账号)。 + var link *model.Link + var err error + link, err = resolveShareDirectLink(d, file) + if err != nil || link == nil { + log.Warnf("免转存失败,回退转存: %v", err) + link, err = resolveQuarkUCShareLink(ctx, d, file, args) + } if err == nil && link != nil { quarkUCShareLinkCache.Set(key, link) } diff --git a/drivers/quark_uc_share/driver_test.go b/drivers/quark_uc_share/driver_test.go index 165cddcc3..dad2df594 100644 --- a/drivers/quark_uc_share/driver_test.go +++ b/drivers/quark_uc_share/driver_test.go @@ -16,15 +16,20 @@ import ( func TestQuarkUCShareLink_CachesByFileID(t *testing.T) { origCache := quarkUCShareLinkCache origResolver := resolveQuarkUCShareLink + origDirect := resolveShareDirectLink quarkUCShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) resolveCalls := 0 resolveQuarkUCShareLink = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { resolveCalls++ return &model.Link{URL: "https://example.com/quark/" + file.GetID()}, nil } + resolveShareDirectLink = func(d *QuarkUCShare, file model.Obj) (*model.Link, error) { + return nil, errors.New("share-direct stub disabled") // 置失败,流程落到 resolveQuarkUCShareLink + } t.Cleanup(func() { quarkUCShareLinkCache = origCache resolveQuarkUCShareLink = origResolver + resolveShareDirectLink = origDirect }) d := &QuarkUCShare{Addition: Addition{ShareToken: "share-token"}, config: driver.Config{Name: "QuarkShare"}} @@ -49,15 +54,20 @@ func TestQuarkUCShareLink_CachesByFileID(t *testing.T) { func TestQuarkUCShareLink_DoesNotCacheErrors(t *testing.T) { origCache := quarkUCShareLinkCache origResolver := resolveQuarkUCShareLink + origDirect := resolveShareDirectLink quarkUCShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) resolveCalls := 0 resolveQuarkUCShareLink = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { resolveCalls++ return nil, errors.New("boom") } + resolveShareDirectLink = func(d *QuarkUCShare, file model.Obj) (*model.Link, error) { + return nil, errors.New("share-direct stub disabled") // 失败 → 落到同样报错的 resolveQuarkUCShareLink + } t.Cleanup(func() { quarkUCShareLinkCache = origCache resolveQuarkUCShareLink = origResolver + resolveShareDirectLink = origDirect }) d := &QuarkUCShare{Addition: Addition{ShareToken: "share-token"}, config: driver.Config{Name: "QuarkShare"}} @@ -73,15 +83,20 @@ func TestQuarkUCShareLink_DoesNotCacheErrors(t *testing.T) { func TestQuarkUCShareLink_DifferentFileIDsDoNotShareCache(t *testing.T) { origCache := quarkUCShareLinkCache origResolver := resolveQuarkUCShareLink + origDirect := resolveShareDirectLink quarkUCShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) resolveCalls := 0 resolveQuarkUCShareLink = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { resolveCalls++ return &model.Link{URL: "https://example.com/quark/" + file.GetID()}, nil } + resolveShareDirectLink = func(d *QuarkUCShare, file model.Obj) (*model.Link, error) { + return nil, errors.New("share-direct stub disabled") // 置失败,流程落到 resolveQuarkUCShareLink + } t.Cleanup(func() { quarkUCShareLinkCache = origCache resolveQuarkUCShareLink = origResolver + resolveShareDirectLink = origDirect }) d := &QuarkUCShare{Addition: Addition{ShareToken: "share-token"}, config: driver.Config{Name: "QuarkShare"}} @@ -93,6 +108,46 @@ func TestQuarkUCShareLink_DifferentFileIDsDoNotShareCache(t *testing.T) { } } +func TestQuarkUCShareLink_PrefersShareDirect(t *testing.T) { + // 免转存(share-direct)为主:成功时不应调用转存路径。 + origCache := quarkUCShareLinkCache + origResolver := resolveQuarkUCShareLink + origDirect := resolveShareDirectLink + quarkUCShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) + resolveCalls := 0 + directCalls := 0 + resolveQuarkUCShareLink = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { + resolveCalls++ + return nil, errors.New("resolve should not be called") + } + resolveShareDirectLink = func(d *QuarkUCShare, file model.Obj) (*model.Link, error) { + directCalls++ + return &model.Link{URL: "https://example.com/share-direct/" + file.GetID()}, nil + } + t.Cleanup(func() { + quarkUCShareLinkCache = origCache + resolveQuarkUCShareLink = origResolver + resolveShareDirectLink = origDirect + }) + + d := &QuarkUCShare{Addition: Addition{ShareToken: "share-token"}, config: driver.Config{Name: "QuarkShare"}} + file := &model.Object{ID: "fid-fidtoken-pid", Name: "video.mp4"} + + link, err := d.Link(context.Background(), file, model.LinkArgs{}) + if err != nil { + t.Fatalf("link: %v", err) + } + if link == nil || link.URL == "" { + t.Fatalf("expected non-empty link") + } + if directCalls != 1 { + t.Fatalf("expected share-direct once, got %d", directCalls) + } + if resolveCalls != 0 { + t.Fatalf("expected resolve not called when share-direct succeeds, got %d", resolveCalls) + } +} + func TestBindRequestDriverUsesSelectedStorageForRequestAndTempDir(t *testing.T) { selected := &quark.QuarkOrUC{TempDirId: "temp-dir-a"} other := &quark.QuarkOrUC{TempDirId: "temp-dir-b"} diff --git a/drivers/quark_uc_share/util.go b/drivers/quark_uc_share/util.go index 17bf0e330..acf54d684 100755 --- a/drivers/quark_uc_share/util.go +++ b/drivers/quark_uc_share/util.go @@ -519,3 +519,96 @@ func (d *QuarkUCShare) getFileToken(binding shareRequestBinding, pid, fid string } return "", errors.New("file not found") } + +// accountIsSVIP 判断当前驱动类型的主账号(master)是否为 SVIP(SUPER_VIP),用于路由原画 vs 免转存。 +// 直接走 GetMasterDriver(name, prefix, 0) 取主账号:不受 DriverRoundRobin 开关影响 +// (GetFirstDriver 在轮询模式下 prefix 传空,会跳过 master 查询、退回到 drivers[0])。 +// 主账号才是 save+下载原画路径实际使用的账号。无主账号返回 false。 +// quark.QuarkOrUC.VIP 在账号 Init() 时由 getVipInfo() 按 member_type 含 "SUPER_VIP" 置位。 +// 声明为 var 以便测试替换(避免单测里 op 未初始化导致死锁)。 +// masterCookie 取当前驱动类型主账号(master)的 Cookie。 +// 夸克 share /file/download 需账号上下文(参考脚本 quarkRequestShareDownload 用 drive.fetch 带账号), +// 匿名请求会失败 → 回退转存。故夸克取链需带主账号 Cookie;UC 匿名即可。无账号返回 ""。 +func (d *QuarkUCShare) masterCookie() string { + name := d.getDriverName() + prefix := conf.UC + if name == "Quark" { + prefix = conf.QUARK + } + storage := op.GetMasterDriver(name, prefix, 0) + if storage == nil { + return "" + } + uc, ok := storage.(*quark.QuarkOrUC) + if !ok { + return "" + } + return uc.Cookie +} + +// resolveShareDirectLink 免转存取链:直接用分享凭据调 /file/download 换直链, +// 不把文件 save 到任何个人账号。匿名(仅 stoken),无账号也可用。 +// 失败时由 Link() 上层回退到 save+delete。声明为 var 以便测试替换(同 resolveQuarkUCShareLink)。 +var resolveShareDirectLink = func(d *QuarkUCShare, file model.Obj) (*model.Link, error) { + // 文件 ID 形如 {fid}-{share_fid_token}-{pdir_fid}(见 fileToObj)。 + parts := strings.SplitN(file.GetID(), "-", 3) + if len(parts) < 2 { + return nil, errors.New("invalid share file id: " + file.GetID()) + } + fileId, fidToken := parts[0], parts[1] + pid := "" + if len(parts) >= 3 { + pid = parts[2] + } + if d.ShareToken == "" { + if err := d.getShareToken(); err != nil { + return nil, err + } + } + // 取链请求 Cookie:UC 走匿名(已验证可行);夸克 share /file/download 需账号上下文,走主账号 Cookie。 + isUC := d.getDriverName() == "UC" + reqCookie := "" + if !isUC { + reqCookie = d.masterCookie() + } + body := base.Json{ + "fids": []string{fileId}, + "fids_token": []string{fidToken}, + "pwd_id": d.ShareId, + "stoken": d.ShareToken, + "speedup_session": "", + } + var resp DownResp + _, err := d.directRequest(reqCookie, "/file/download", http.MethodPost, func(req *resty.Request) { + req.SetBody(body) + }, &resp) + // fid_token 失效时,按 pid 重新换取 share_fid_token 后重试一次(复用 saveFile 的回退策略)。 + if err != nil && strings.Contains(err.Error(), "token校验异常") && pid != "" { + if newToken, e := d.getFileToken(nil, pid, fileId); e == nil && newToken != "" { + body["fids_token"] = []string{newToken} + _, err = d.directRequest(reqCookie, "/file/download", http.MethodPost, func(req *resty.Request) { + req.SetBody(body) + }, &resp) + } + } + if err != nil { + return nil, err + } + if len(resp.Data) == 0 || resp.Data[0].DownloadUrl == "" { + return nil, errors.New("empty share download url") + } + downloadUrl := resp.Data[0].DownloadUrl + // 后端代理用此 Header(魔法 Referer)绕过 checkplay。 + header := http.Header{ + "User-Agent": []string{d.conf.ua}, + "Referer": []string{downloadUrl + "\\ "}, + "Accept-Encoding": []string{"identity"}, + } + log.Infof("[%v] 免转存直链 %v %v", d.getDriverName(), file.GetName(), file.GetSize()) + // 客户端代理(alist-tvbox 等)直接用原始 URL 且自带 header,无法应用 link.Header。 + // 追加片段标记 #x-referer=raw,供客户端解析后改用魔法 Referer;片段不会发往 CDN。 + return &model.Link{ + URL: downloadUrl + "#x-referer=raw", + Header: header, + }, nil +} From 64464c67d9b984978ab3bec90e18e9d4380b866e Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 21 Jul 2026 13:09:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(share):=20=E6=81=A2=E5=A4=8D=20SVIP=20?= =?UTF-8?q?=E4=B8=BB=E8=B4=A6=E5=8F=B7=E8=BD=AC=E5=AD=98=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 有 SVIP 主账号 → 转存(save+download 原画)为主、免转存兜底; 非 SVIP / 无账号 → 免转存(share-direct)为主、转存兜底。 - 超大文件(如 ISO)免转存单直链不稳,SVIP 走转存更可靠播放。 - 恢复 accountIsSVIP 及 SVIP 路由测试(Transferred/Saved-fallback 覆盖)。 Co-Authored-By: Claude --- drivers/quark_uc_share/driver.go | 21 +++++++--- drivers/quark_uc_share/driver_test.go | 59 ++++++++++++++++++++++++++- drivers/quark_uc_share/util.go | 18 ++++++++ 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/drivers/quark_uc_share/driver.go b/drivers/quark_uc_share/driver.go index 8f2b9b35e..67b01d595 100755 --- a/drivers/quark_uc_share/driver.go +++ b/drivers/quark_uc_share/driver.go @@ -99,15 +99,24 @@ func (d *QuarkUCShare) Link(ctx context.Context, file model.Obj, args model.Link return link, nil } - // 免转存(share-direct)为主:用分享凭据直接换直链,得到原始文件=原画, - // 且不转存、不占空间、无需账号(SVIP/非 SVIP 一视同仁)。share-direct 已由魔法 Referer - // 绕过 checkplay,UC 验证可行,夸克同后端同手法。失败再回退 save+delete(需账号)。 + // 按主账号类型选主路径,两条路互为兜底: + // 有 SVIP 主账号 → 转存(save+download 原画,全速,超大文件/ISO 可靠)为主,免转存兜底 + // 否则(非 SVIP / 无账号) → 免转存(share-direct,原画、省空间省等待)为主,转存兜底 + // 超大文件(如 ISO)免转存直链不稳,SVIP 走转存更可靠。 var link *model.Link var err error - link, err = resolveShareDirectLink(d, file) - if err != nil || link == nil { - log.Warnf("免转存失败,回退转存: %v", err) + if accountIsSVIP(d) { link, err = resolveQuarkUCShareLink(ctx, d, file, args) + if err != nil || link == nil { + log.Warnf("SVIP 转存失败,回退免转存: %v", err) + link, err = resolveShareDirectLink(d, file) + } + } else { + link, err = resolveShareDirectLink(d, file) + if err != nil || link == nil { + log.Warnf("免转存失败,回退转存: %v", err) + link, err = resolveQuarkUCShareLink(ctx, d, file, args) + } } if err == nil && link != nil { quarkUCShareLinkCache.Set(key, link) diff --git a/drivers/quark_uc_share/driver_test.go b/drivers/quark_uc_share/driver_test.go index dad2df594..54fa7849e 100644 --- a/drivers/quark_uc_share/driver_test.go +++ b/drivers/quark_uc_share/driver_test.go @@ -17,12 +17,14 @@ func TestQuarkUCShareLink_CachesByFileID(t *testing.T) { origCache := quarkUCShareLinkCache origResolver := resolveQuarkUCShareLink origDirect := resolveShareDirectLink + origSVIP := accountIsSVIP quarkUCShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) resolveCalls := 0 resolveQuarkUCShareLink = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { resolveCalls++ return &model.Link{URL: "https://example.com/quark/" + file.GetID()}, nil } + accountIsSVIP = func(d *QuarkUCShare) bool { return false } // 非 SVIP:免转存为主 resolveShareDirectLink = func(d *QuarkUCShare, file model.Obj) (*model.Link, error) { return nil, errors.New("share-direct stub disabled") // 置失败,流程落到 resolveQuarkUCShareLink } @@ -30,6 +32,7 @@ func TestQuarkUCShareLink_CachesByFileID(t *testing.T) { quarkUCShareLinkCache = origCache resolveQuarkUCShareLink = origResolver resolveShareDirectLink = origDirect + accountIsSVIP = origSVIP }) d := &QuarkUCShare{Addition: Addition{ShareToken: "share-token"}, config: driver.Config{Name: "QuarkShare"}} @@ -55,12 +58,14 @@ func TestQuarkUCShareLink_DoesNotCacheErrors(t *testing.T) { origCache := quarkUCShareLinkCache origResolver := resolveQuarkUCShareLink origDirect := resolveShareDirectLink + origSVIP := accountIsSVIP quarkUCShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) resolveCalls := 0 resolveQuarkUCShareLink = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { resolveCalls++ return nil, errors.New("boom") } + accountIsSVIP = func(d *QuarkUCShare) bool { return false } // 非 SVIP:免转存为主 resolveShareDirectLink = func(d *QuarkUCShare, file model.Obj) (*model.Link, error) { return nil, errors.New("share-direct stub disabled") // 失败 → 落到同样报错的 resolveQuarkUCShareLink } @@ -68,6 +73,7 @@ func TestQuarkUCShareLink_DoesNotCacheErrors(t *testing.T) { quarkUCShareLinkCache = origCache resolveQuarkUCShareLink = origResolver resolveShareDirectLink = origDirect + accountIsSVIP = origSVIP }) d := &QuarkUCShare{Addition: Addition{ShareToken: "share-token"}, config: driver.Config{Name: "QuarkShare"}} @@ -84,12 +90,14 @@ func TestQuarkUCShareLink_DifferentFileIDsDoNotShareCache(t *testing.T) { origCache := quarkUCShareLinkCache origResolver := resolveQuarkUCShareLink origDirect := resolveShareDirectLink + origSVIP := accountIsSVIP quarkUCShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) resolveCalls := 0 resolveQuarkUCShareLink = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { resolveCalls++ return &model.Link{URL: "https://example.com/quark/" + file.GetID()}, nil } + accountIsSVIP = func(d *QuarkUCShare) bool { return false } // 非 SVIP:免转存为主 resolveShareDirectLink = func(d *QuarkUCShare, file model.Obj) (*model.Link, error) { return nil, errors.New("share-direct stub disabled") // 置失败,流程落到 resolveQuarkUCShareLink } @@ -97,6 +105,7 @@ func TestQuarkUCShareLink_DifferentFileIDsDoNotShareCache(t *testing.T) { quarkUCShareLinkCache = origCache resolveQuarkUCShareLink = origResolver resolveShareDirectLink = origDirect + accountIsSVIP = origSVIP }) d := &QuarkUCShare{Addition: Addition{ShareToken: "share-token"}, config: driver.Config{Name: "QuarkShare"}} @@ -108,11 +117,12 @@ func TestQuarkUCShareLink_DifferentFileIDsDoNotShareCache(t *testing.T) { } } -func TestQuarkUCShareLink_PrefersShareDirect(t *testing.T) { - // 免转存(share-direct)为主:成功时不应调用转存路径。 +func TestQuarkUCShareLink_NonSVIPPrefersShareDirect(t *testing.T) { + // 非 SVIP:免转存为主,成功时不调用转存。 origCache := quarkUCShareLinkCache origResolver := resolveQuarkUCShareLink origDirect := resolveShareDirectLink + origSVIP := accountIsSVIP quarkUCShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) resolveCalls := 0 directCalls := 0 @@ -120,6 +130,7 @@ func TestQuarkUCShareLink_PrefersShareDirect(t *testing.T) { resolveCalls++ return nil, errors.New("resolve should not be called") } + accountIsSVIP = func(d *QuarkUCShare) bool { return false } resolveShareDirectLink = func(d *QuarkUCShare, file model.Obj) (*model.Link, error) { directCalls++ return &model.Link{URL: "https://example.com/share-direct/" + file.GetID()}, nil @@ -128,6 +139,7 @@ func TestQuarkUCShareLink_PrefersShareDirect(t *testing.T) { quarkUCShareLinkCache = origCache resolveQuarkUCShareLink = origResolver resolveShareDirectLink = origDirect + accountIsSVIP = origSVIP }) d := &QuarkUCShare{Addition: Addition{ShareToken: "share-token"}, config: driver.Config{Name: "QuarkShare"}} @@ -148,6 +160,49 @@ func TestQuarkUCShareLink_PrefersShareDirect(t *testing.T) { } } +func TestQuarkUCShareLink_SVIPPrefersSaveAndDelete(t *testing.T) { + // SVIP 主账号:转存(save+download)为主,成功时不调用免转存(超大文件/ISO 更可靠)。 + origCache := quarkUCShareLinkCache + origResolver := resolveQuarkUCShareLink + origDirect := resolveShareDirectLink + origSVIP := accountIsSVIP + quarkUCShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) + resolveCalls := 0 + directCalls := 0 + resolveQuarkUCShareLink = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { + resolveCalls++ + return &model.Link{URL: "https://example.com/saved/" + file.GetID()}, nil + } + accountIsSVIP = func(d *QuarkUCShare) bool { return true } // SVIP:转存为主 + resolveShareDirectLink = func(d *QuarkUCShare, file model.Obj) (*model.Link, error) { + directCalls++ + return nil, errors.New("share-direct should not be called") + } + t.Cleanup(func() { + quarkUCShareLinkCache = origCache + resolveQuarkUCShareLink = origResolver + resolveShareDirectLink = origDirect + accountIsSVIP = origSVIP + }) + + d := &QuarkUCShare{Addition: Addition{ShareToken: "share-token"}, config: driver.Config{Name: "QuarkShare"}} + file := &model.Object{ID: "fid-fidtoken-pid", Name: "big.iso"} + + link, err := d.Link(context.Background(), file, model.LinkArgs{}) + if err != nil { + t.Fatalf("link: %v", err) + } + if link == nil || link.URL == "" { + t.Fatalf("expected non-empty link") + } + if resolveCalls != 1 { + t.Fatalf("expected resolve (save+delete) once for SVIP, got %d", resolveCalls) + } + if directCalls != 0 { + t.Fatalf("expected share-direct not called when SVIP save+delete succeeds, got %d", directCalls) + } +} + func TestBindRequestDriverUsesSelectedStorageForRequestAndTempDir(t *testing.T) { selected := &quark.QuarkOrUC{TempDirId: "temp-dir-a"} other := &quark.QuarkOrUC{TempDirId: "temp-dir-b"} diff --git a/drivers/quark_uc_share/util.go b/drivers/quark_uc_share/util.go index acf54d684..bffd2bf29 100755 --- a/drivers/quark_uc_share/util.go +++ b/drivers/quark_uc_share/util.go @@ -526,6 +526,24 @@ func (d *QuarkUCShare) getFileToken(binding shareRequestBinding, pid, fid string // 主账号才是 save+下载原画路径实际使用的账号。无主账号返回 false。 // quark.QuarkOrUC.VIP 在账号 Init() 时由 getVipInfo() 按 member_type 含 "SUPER_VIP" 置位。 // 声明为 var 以便测试替换(避免单测里 op 未初始化导致死锁)。 +// accountIsSVIP 判断当前驱动类型主账号是否为 SVIP(SUPER_VIP):有主账号且 SVIP 返回 true。 +// 路由:有 SVIP 主账号 → 转存(save+download 原画,全速,超大文件/ISO 可靠);否则 → 免转存(share-direct)。 +// quark.QuarkOrUC.VIP 在账号 Init() 时由 getVipInfo() 按 member_type 含 "SUPER_VIP" 置位。 +// 声明为 var 以便测试替换(避免单测里 op 未初始化导致 GetMasterDriver 死锁)。 +var accountIsSVIP = func(d *QuarkUCShare) bool { + name := d.getDriverName() + prefix := conf.UC + if name == "Quark" { + prefix = conf.QUARK + } + storage := op.GetMasterDriver(name, prefix, 0) + if storage == nil { + return false + } + uc, ok := storage.(*quark.QuarkOrUC) + return ok && uc.VIP +} + // masterCookie 取当前驱动类型主账号(master)的 Cookie。 // 夸克 share /file/download 需账号上下文(参考脚本 quarkRequestShareDownload 用 drive.fetch 带账号), // 匿名请求会失败 → 回退转存。故夸克取链需带主账号 Cookie;UC 匿名即可。无账号返回 ""。