// 115 开放平台 OAuth 授权会话与 Token 维护。 // // 授权方式(与 QMediaSync 一致): // - built_in_appid:官方应用目录设备码扫码(无回调,轮询 qrcodeapi) // - qmediasync / mqfamily:中继授权(浏览器授权后中继 POST 回本服务回调端点, // 需配置 strm.115_relay_key 共享密钥解密) // - moviepilot:MoviePilot 授权服务(轮询其 /u115/token) // - clouddrive:CloudDrive 中转(115 授权页 → zhenyunpan 换 token 后回跳) // // 授权成功后将 access_token / refresh_token 写入网盘账号配置(加密存储)。 package service import ( "context" "errors" "fmt" "strconv" "strings" "time" "go.uber.org/zap" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/service/cloud115" ) // strm115RelayKeySetting 是中继授权共享 AES 密钥的设置键。 const Strm115RelayKeySetting = "strm.115_relay_key" const strm115AuthSessionTTL = 6 * time.Minute // strm115AuthSession 是一次 115 授权会话(内存态,重启后需重新授权)。 type strm115AuthSession struct { ID string AccountID string Source cloud115.Source Mode string // qrcode / poll / callback QrCode *cloud115.QrCodeDataReturn State string Provider cloud115.OAuthProvider TokenCh chan *cloud115.OAuthTokenResult CreatedAt time.Time } // Strm115AuthStartResult 是授权发起结果。 type Strm115AuthStartResult struct { SessionID string `json:"session_id"` Mode string `json:"mode"` // qrcode / url AuthURL string `json:"auth_url,omitempty"` State string `json:"state,omitempty"` ExpiresIn int64 `json:"expires_in,omitempty"` QRCode *struct { UID string `json:"uid"` Time int64 `json:"time"` Sign string `json:"sign"` Qrcode string `json:"qrcode"` } `json:"qrcode,omitempty"` } // Strm115AuthStatus 是授权轮询结果。 type Strm115AuthStatus struct { Status string `json:"status"` // waiting / scanned / confirmed / expired Tip string `json:"tip"` } // List115Sources 返回可用的 115 授权来源列表。 func (s *StrmService) List115Sources(ctx context.Context) (map[string][]cloud115.Source, error) { out := map[string][]cloud115.Source{} out["built_in"] = cloud115.BuiltInAppIDSources() out["relay"] = cloud115.BuiltInRelaySources() out["third_party"] = cloud115.ThirdPartySources() return out, nil } // Start115OAuth 发起 115 授权;redirectURL 为空时使用服务自身地址。 func (s *StrmService) Start115OAuth(ctx context.Context, accountID, authSource, appID, provider, redirectURL string) (*Strm115AuthStartResult, error) { acct, err := s.repo.StrmAccount.FindByID(ctx, accountID) if err != nil || acct == nil { return nil, errNotFoundOr(err, "网盘账号不存在") } if acct.Provider != model.StrmProvider115 { return nil, errors.New("该账号不是 115 网盘账号") } source, err := resolve115Source(authSource, provider, appID) if err != nil { return nil, err } sessionID := newStrmID() session := &strm115AuthSession{ ID: sessionID, AccountID: accountID, Source: source, TokenCh: make(chan *cloud115.OAuthTokenResult, 1), CreatedAt: time.Now(), } result := &Strm115AuthStartResult{SessionID: sessionID} switch authSource { case string(cloud115.SourceTypeBuiltInAppID), string(cloud115.SourceTypeCustomAppID): // 官方设备码扫码 client := cloud115.NewOpenClient(source.AppID, "", "") qr, err := client.GetQrCode() if err != nil { return nil, fmt.Errorf("获取 115 授权二维码失败:%w", err) } session.Mode = "qrcode" session.QrCode = qr session.Provider, _ = cloud115.GetOAuthProvider(source) result.Mode = "qrcode" result.ExpiresIn = 300 result.QRCode = &struct { UID string `json:"uid"` Time int64 `json:"time"` Sign string `json:"sign"` Qrcode string `json:"qrcode"` }{UID: qr.Uid, Time: qr.Time, Sign: qr.Sign, Qrcode: qr.Qrcode} default: // 中继 / MoviePilot / CloudDrive 网页授权 oauthProvider, err := cloud115.GetOAuthProvider(source) if err != nil { return nil, err } oauthResult, err := oauthProvider.BuildAuth(ctx, cloud115.OAuthURLRequest{ Source: source, RedirectURL: redirectURL, AuthorizationID: sessionID, }) if err != nil { return nil, fmt.Errorf("发起 115 授权失败:%w", err) } session.Provider = oauthProvider session.State = oauthResult.State if oauthResult.Polling { session.Mode = "poll" } else { session.Mode = "callback" } result.Mode = "url" result.AuthURL = oauthResult.AuthURL result.State = oauthResult.State result.ExpiresIn = oauthResult.ExpiresIn if result.ExpiresIn == 0 { result.ExpiresIn = 300 } } s.mu.Lock() if s.oauthSessions == nil { s.oauthSessions = map[string]*strm115AuthSession{} } s.oauthSessions[sessionID] = session s.mu.Unlock() s.sweep115AuthSessions() return result, nil } // Poll115OAuth 轮询授权状态;确认后把 token 写入账号。 func (s *StrmService) Poll115OAuth(ctx context.Context, sessionID string) (*Strm115AuthStatus, error) { s.mu.Lock() session := s.oauthSessions[sessionID] s.mu.Unlock() if session == nil { return nil, errors.New("授权会话不存在或已过期,请重新发起") } if time.Since(session.CreatedAt) > strm115AuthSessionTTL { s.drop115AuthSession(sessionID) return &Strm115AuthStatus{Status: "expired", Tip: "授权会话已过期"}, nil } var status cloud115.QrCodeScanStatus var token *cloud115.OAuthTokenResult switch session.Mode { case "qrcode": client := cloud115.NewOpenClient(session.Source.AppID, "", "") st, err := client.QrCodeScanStatus(&session.QrCode.QrCodeData) if err != nil { return nil, fmt.Errorf("查询扫码状态失败:%w", err) } status = st if st == cloud115.QrCodeScanStatusConfirmed { t, err := client.GetToken(session.QrCode) if err != nil { return nil, fmt.Errorf("获取访问令牌失败:%w", err) } token = &cloud115.OAuthTokenResult{ AccessToken: t.AccessToken, RefreshToken: t.RefreshToken, ExpiresIn: t.ExpiresIn, Done: true, } } case "poll": if session.Provider == nil { return nil, errors.New("授权服务不可用") } t, err := session.Provider.Poll(ctx, session.State) if err != nil { return nil, err } if t.Done { token = &t } else { return &Strm115AuthStatus{Status: "waiting", Tip: "等待授权确认"}, nil } case "callback": select { case t := <-session.TokenCh: if t == nil || !t.Done { return &Strm115AuthStatus{Status: "waiting", Tip: "等待授权确认"}, nil } token = t default: return &Strm115AuthStatus{Status: "waiting", Tip: "正在等待网页授权完成(5 分钟内)"}, nil } default: return nil, fmt.Errorf("未知的授权模式:%s", session.Mode) } switch status { case cloud115.QrCodeScanStatusNotScanned: return &Strm115AuthStatus{Status: "waiting", Tip: "等待扫码"}, nil case cloud115.QrCodeScanStatusScanned: return &Strm115AuthStatus{Status: "scanned", Tip: "已扫码,请在 115 客户端确认"}, nil case cloud115.QrCodeScanStatusExpired: s.drop115AuthSession(sessionID) return &Strm115AuthStatus{Status: "expired", Tip: "二维码已过期"}, nil } if token == nil || !token.Done { return &Strm115AuthStatus{Status: "waiting", Tip: "授权处理中"}, nil } if err := s.save115OAuthToken(ctx, session, token); err != nil { return nil, err } s.drop115AuthSession(sessionID) return &Strm115AuthStatus{Status: "confirmed", Tip: "授权成功"}, nil } // Handle115OAuthCallback 处理中继/CloudDrive 授权回跳(公开端点)。 func (s *StrmService) Handle115OAuthCallback(ctx context.Context, payload map[string]string) error { authID := strings.TrimSpace(payload["authorization_id"]) if authID == "" { return errors.New("缺少 authorization_id") } s.mu.Lock() session := s.oauthSessions[authID] s.mu.Unlock() if session == nil { return errors.New("授权会话不存在或已过期") } if session.Provider == nil { return errors.New("该授权会话不支持回调") } token, err := session.Provider.Confirm(ctx, payload) if err != nil { return err } if !token.Done { return errors.New("回调未包含有效访问凭证") } select { case session.TokenCh <- &token: default: } return nil } // save115OAuthToken 把授权 token 写入账号配置并附带用户信息。 func (s *StrmService) save115OAuthToken(ctx context.Context, session *strm115AuthSession, token *cloud115.OAuthTokenResult) error { acct, err := s.repo.StrmAccount.FindByID(ctx, session.AccountID) if err != nil || acct == nil { return errNotFoundOr(err, "网盘账号不存在") } cfg, err := s.strmAccountConfig(acct) if err != nil { return err } cfg["app_id"] = session.Source.AppID cfg["access_token"] = s.crypto.Encrypt(token.AccessToken) cfg["refresh_token"] = s.crypto.Encrypt(token.RefreshToken) // 尝试补充用户信息(失败不阻塞授权) if client := cloud115.NewOpenClient(session.Source.AppID, token.AccessToken, token.RefreshToken); client != nil { if info, err := client.FetchUserInfo(ctx); err == nil && info != nil { cfg["user_id"] = info.UserId.String() cfg["user_name"] = info.UserName if strings.TrimSpace(acct.Name) == "" || strings.HasPrefix(acct.Name, "115") || acct.Name == providerLabel(model.StrmProvider115) { acct.Name = firstNonEmpty(info.UserName, acct.Name) } } } enc, err := s.strmAccountConfigJSON(cfg, false) if err != nil { return err } acct.Config = enc now := time.Now() acct.LastTestAt = &now acct.LastTestResult = "授权成功" acct.LastTestOK = true return s.repo.StrmAccount.Update(ctx, acct) } func (s *StrmService) drop115AuthSession(sessionID string) { s.mu.Lock() delete(s.oauthSessions, sessionID) s.mu.Unlock() } func (s *StrmService) sweep115AuthSessions() { s.mu.Lock() defer s.mu.Unlock() for id, session := range s.oauthSessions { if time.Since(session.CreatedAt) > strm115AuthSessionTTL { delete(s.oauthSessions, id) } } } // resolve115Source 解析授权来源。 func resolve115Source(authSource, provider, appID string) (cloud115.Source, error) { authSource = strings.TrimSpace(authSource) appID = strings.TrimSpace(appID) switch authSource { case string(cloud115.SourceTypeBuiltInAppID): if source, ok := cloud115.FindSource(cloud115.SourceTypeBuiltInAppID, cloud115.ProviderOfficialPKCE, appID); ok { return source, nil } return cloud115.Source{}, fmt.Errorf("未知的内置应用 ID:%s", appID) case string(cloud115.SourceTypeCustomAppID): if appID == "" { return cloud115.Source{}, errors.New("自定义 APP ID 不能为空") } return cloud115.Source{SourceType: cloud115.SourceTypeCustomAppID, Provider: cloud115.ProviderOfficialPKCE, AppID: appID, AppName: cloud115.CustomAppName, DisplayName: cloud115.CustomAppName}, nil case string(cloud115.SourceTypeBuiltInRelay): if source, ok := cloud115.FindSource(cloud115.SourceTypeBuiltInRelay, cloud115.AuthProvider(provider), appID); ok { return source, nil } return cloud115.Source{}, errors.New("未知的中继授权服务") case string(cloud115.SourceTypeThirdPartyService): if source, ok := cloud115.FindSource(cloud115.SourceTypeThirdPartyService, cloud115.AuthProvider(provider), appID); ok { return source, nil } return cloud115.Source{}, errors.New("未知的第三方授权服务") default: return cloud115.Source{}, errors.New("不支持的授权来源") } } // refresh115TokensLoop 定期刷新 115 开放平台访问令牌。 func (s *StrmService) refresh115TokensLoop(ctx context.Context) { ticker := time.NewTicker(30 * time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-s.stopCh: return case <-ticker.C: s.refresh115TokensOnce(ctx) } } } func (s *StrmService) refresh115TokensOnce(ctx context.Context) { accounts, err := s.repo.StrmAccount.List(ctx) if err != nil { return } for i := range accounts { acct := &accounts[i] if acct.Provider != model.StrmProvider115 || !acct.Enabled { continue } cfg, err := s.strmAccountConfig(acct) if err != nil || cfg["access_token"] == "" || cfg["refresh_token"] == "" { continue } // 临期才刷:115 refresh_token 是一次性轮转,无条件周期刷新会与 // 运行中任务的自动刷新互相作废对方的凭据。24h 内刷新过(含运行 // 中回调落库)就跳过;access_token 有效期远长于 24h。 if last := strings.TrimSpace(cfg["token_refreshed_at"]); last != "" { if ts, perr := strconv.ParseInt(last, 10, 64); perr == nil { if time.Since(time.Unix(ts, 0)) < 24*time.Hour { continue } } } client := cloud115.NewOpenClient(cfg["app_id"], cfg["access_token"], cfg["refresh_token"]) token, err := client.RefreshToken(cfg["refresh_token"]) if err != nil { msg := "令牌刷新失败:" + err.Error() if cloud115.IsRefreshTokenDead(err) { msg = "授权已失效,请重新授权:" + err.Error() cfg["access_token"] = "" } now := time.Now() acct.LastTestAt = &now acct.LastTestResult = msg acct.LastTestOK = false if cfg["access_token"] == "" { enc, encErr := s.strmAccountConfigJSON(cfg, false) if encErr == nil { acct.Config = enc } } _ = s.repo.StrmAccount.Update(ctx, acct) s.log.Warn("115 token refresh failed", zap.String("account", acct.Name), zap.String("error", msg)) continue } cfg["access_token"] = s.crypto.Encrypt(token.AccessToken) cfg["refresh_token"] = s.crypto.Encrypt(token.RefreshToken) cfg["token_refreshed_at"] = strconv.FormatInt(time.Now().Unix(), 10) enc, err := s.strmAccountConfigJSON(cfg, false) if err != nil { continue } acct.Config = enc now := time.Now() acct.LastTestAt = &now acct.LastTestResult = "ok" acct.LastTestOK = true if err := s.repo.StrmAccount.Update(ctx, acct); err != nil { s.log.Warn("update 115 token failed", zap.Error(err)) } } } // persist115Tokens 把运行中任务自动刷新得到的新令牌加密写回账号配置, // 并记录刷新时间供定时刷新线程做临期判断。 func (s *StrmService) persist115Tokens(accountID, accessToken, refreshToken string) { ctx := context.Background() acct, err := s.repo.StrmAccount.FindByID(ctx, accountID) if err != nil || acct == nil { return } cfg, err := s.strmAccountConfig(acct) if err != nil { return } cfg["access_token"] = s.crypto.Encrypt(accessToken) cfg["refresh_token"] = s.crypto.Encrypt(refreshToken) cfg["token_refreshed_at"] = strconv.FormatInt(time.Now().Unix(), 10) enc, err := s.strmAccountConfigJSON(cfg, false) if err != nil { return } acct.Config = enc now := time.Now() acct.LastTestAt = &now acct.LastTestResult = "ok" acct.LastTestOK = true if err := s.repo.StrmAccount.Update(ctx, acct); err != nil { s.log.Warn("persist refreshed 115 token failed", zap.Error(err), zap.String("account", acct.Name)) return } s.log.Info("115 token refreshed and persisted", zap.String("account", acct.Name)) } // sync115RelayKey 把设置里的中继密钥同步给 cloud115(启动与设置保存时调用)。 func (s *StrmService) sync115RelayKey(ctx context.Context) { cloud115.RelayEncryptionKey = s.strmSetting(ctx, Strm115RelayKeySetting) } // newStrmID 生成授权会话 ID。 func newStrmID() string { return "auth-" + strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "") + "-" + cloud115.RandomString(8) }