添加strm生成

添加strm
This commit is contained in:
truewhile
2026-08-24 14:36:17 +08:00
parent cd1297a30f
commit c55d29ae84
47 changed files with 11956 additions and 4 deletions
+421
View File
@@ -0,0 +1,421 @@
// 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"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/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
}
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)
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))
}
}
}
// 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)
}