添加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
+238
View File
@@ -0,0 +1,238 @@
// 115 开放平台 HTTP 客户端(移植自 QMediaSync 的 v115open,去掉 resty 依赖,
// 使用 net/http + 简单限流重试;只保留只读能力:授权/列目录/详情/下载直链)。
package cloud115
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
)
// OpenClient 是 115 开放平台客户端。
type OpenClient struct {
AppID string
HTTP *http.Client
AccessToken string
RefreshTokenStr string
// 全局 QPS 限流(115 开放平台免费额度较低)
lastSecond int64
reqInSecond int64
}
var openClientMu sync.Mutex
// NewOpenClient 构造客户端。
func NewOpenClient(appID, accessToken, refreshToken string) *OpenClient {
return &OpenClient{
AppID: appID,
HTTP: &http.Client{Timeout: 60 * time.Second},
AccessToken: accessToken,
RefreshTokenStr: refreshToken,
}
}
// SetAuthToken 更新认证令牌。
func (c *OpenClient) SetAuthToken(accessToken, refreshToken string) {
c.AccessToken = accessToken
c.RefreshTokenStr = refreshToken
}
// throttle 简单的每秒限流(默认 4 QPS,115 免费应用限额约 5 QPS)。
func (c *OpenClient) throttle(n int) {
for i := 0; i < n; i++ {
now := time.Now().Unix()
last := atomic.LoadInt64(&c.lastSecond)
if last != now {
if atomic.CompareAndSwapInt64(&c.lastSecond, last, now) {
atomic.StoreInt64(&c.reqInSecond, 0)
}
}
count := atomic.LoadInt64(&c.reqInSecond)
if count >= 4 {
time.Sleep(300 * time.Millisecond)
i--
continue
}
if atomic.CompareAndSwapInt64(&c.reqInSecond, count, count+1) {
return
}
time.Sleep(50 * time.Millisecond)
i--
}
}
// RespState 兼容 115 不同端点返回的 state 类型(proapi 返回布尔、passport 返回数字)。
type RespState bool
func (s *RespState) UnmarshalJSON(data []byte) error {
switch string(data) {
case "true", "1":
*s = true
return nil
case "false", "0", "null", "":
*s = false
return nil
}
var n float64
if err := json.Unmarshal(data, &n); err != nil {
return fmt.Errorf("115: 无法解析 state 字段 %s", string(data))
}
*s = n != 0
return nil
}
// RespBase 是 115 开放平台统一响应外壳。
type RespBase struct {
State RespState `json:"state"`
Code int `json:"code"`
Errno int `json:"errno"`
Message string `json:"message"`
Error string `json:"error"`
Data json.RawMessage `json:"data"`
Raw json.RawMessage `json:"-"` // 原始响应体(外层附加字段用)
}
// doJSON 执行 GET 请求并解析为统一响应;带 AccessToken(access=true 时)。
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int) (*RespBase, error) {
var lastErr error
for attempt := 0; attempt <= retries; attempt++ {
c.throttle(1)
req, err := c.buildRequest(ctx, method, rawURL, form, access)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
lastErr = err
if attempt < retries {
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
continue
}
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
_ = resp.Body.Close()
if readErr != nil {
lastErr = readErr
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
lastErr = fmt.Errorf("115 接口返回 HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(body)))
if attempt < retries {
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
continue
}
return nil, lastErr
}
var base RespBase
if err := json.Unmarshal(body, &base); err != nil {
return nil, fmt.Errorf("115 接口响应解析失败:%w", err)
}
base.Raw = body
if base.State {
return &base, nil
}
// 业务失败:限流/Token 错误不重试,其余按配置重试
if IsThrottleCode(base.Code) || isTokenCode(base.Code) {
return &base, nil
}
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115 接口调用失败")
if attempt < retries {
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
continue
}
return &base, lastErr
}
return nil, lastErr
}
func (c *OpenClient) buildRequest(ctx context.Context, method, rawURL string, form map[string]string, access bool) (*http.Request, error) {
method = strings.ToUpper(method)
var body io.Reader
if method == http.MethodPost && len(form) > 0 {
values := url.Values{}
for k, v := range form {
values.Set(k, v)
}
body = bytes.NewBufferString(values.Encode())
} else if method == http.MethodGet && len(form) > 0 {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
query := u.Query()
for k, v := range form {
query.Set(k, v)
}
u.RawQuery = query.Encode()
rawURL = u.String()
}
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", DefaultUA)
if method == http.MethodPost && len(form) > 0 {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if access && c.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
}
return req, nil
}
// doAuthJSON 带 AccessToken 的业务请求。
func (c *OpenClient) doAuthJSON(ctx context.Context, method, rawURL string, form map[string]string, retries int) (*RespBase, error) {
return c.doJSON(ctx, method, rawURL, form, true, retries)
}
// IsThrottleCode 判断是否为限流错误码。
func IsThrottleCode(code int) bool {
return code == RequestMaxLimitCode || code == RequestRateLimitCode
}
func isTokenCode(code int) bool {
switch code {
case AccessTokenAuthFail, AccessAuthInvalid, AccessTokenExpiryCode, RefreshTokenInvalid:
return true
}
return false
}
// openList 解析 data 为对象或数组(StructOrArray 语义)。
func openList[T any](raw json.RawMessage) ([]T, error) {
var single T
if err := json.Unmarshal(raw, &single); err == nil {
return []T{single}, nil
}
var arr []T
if err := json.Unmarshal(raw, &arr); err == nil {
return arr, nil
}
return nil, fmt.Errorf("115: data 既不是对象也不是数组")
}
// openFirstList 取 data 的第一个元素。
func openFirstList[T any](raw json.RawMessage) (*T, error) {
items, err := openList[T](raw)
if err != nil || len(items) == 0 {
return nil, err
}
return &items[0], nil
}
func firstOrEmpty(m map[string]downloadURLData) downloadURLData {
for _, v := range m {
return v
}
return downloadURLData{}
}
+323
View File
@@ -0,0 +1,323 @@
package cloud115
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// mockTransport 用 httptest server 替换 API 基址。
func mockAPI(t *testing.T, handler http.HandlerFunc) func() {
t.Helper()
pro := httptest.NewServer(handler)
passport := httptest.NewServer(handler)
qr := httptest.NewServer(handler)
oldPro, oldPassport, oldQR := ProAPIBase, PassportAPIBase, QRCodeAPIBase
ProAPIBase, PassportAPIBase, QRCodeAPIBase = pro.URL, passport.URL, qr.URL
t.Cleanup(func() {
ProAPIBase, PassportAPIBase, QRCodeAPIBase = oldPro, oldPassport, oldQR
pro.Close()
passport.Close()
qr.Close()
})
return func() {}
}
func TestGetQrCode(t *testing.T) {
var called bool
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
called = true
if r.URL.Path != "/open/authDeviceCode" {
t.Errorf("unexpected path %s", r.URL.Path)
}
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.PostFormValue("client_id") != "100195125" {
t.Errorf("bad client_id %q", r.PostFormValue("client_id"))
}
if r.PostFormValue("code_challenge") == "" {
t.Errorf("missing code_challenge")
}
w.Write([]byte(`{"state":true,"data":{"uid":"U1","time":1700,"sign":"S1","qrcode":"https://img/qr.png"}}`))
})
c := NewOpenClient("100195125", "", "")
qr, err := c.GetQrCode()
if err != nil {
t.Fatalf("get qr: %v", err)
}
if !called {
t.Fatal("request not hit")
}
if qr.Uid != "U1" || qr.Qrcode == "" || qr.CodeVerifier == "" || len(qr.CodeVerifier) != 64 {
t.Fatalf("bad qr data: %#v", qr)
}
}
func TestQrCodeScanStatusSequence(t *testing.T) {
calls := 0
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/get/status/" {
t.Errorf("unexpected path %s", r.URL.Path)
}
calls++
switch calls {
case 1:
w.Write([]byte(`{"state":true,"data":{"status":0}}`))
case 2:
w.Write([]byte(`{"state":true,"data":{"status":1}}`))
default:
w.Write([]byte(`{"state":true,"data":{"status":2}}`))
}
})
c := NewOpenClient("", "", "")
code := &QrCodeData{Uid: "U1", Time: 1700, Sign: "S1"}
want := []QrCodeScanStatus{QrCodeScanStatusNotScanned, QrCodeScanStatusScanned, QrCodeScanStatusConfirmed}
for i, exp := range want {
got, err := c.QrCodeScanStatus(code)
if err != nil {
t.Fatalf("status %d: %v", i, err)
}
if got != exp {
t.Fatalf("status %d: got %v want %v", i, got, exp)
}
}
}
func TestGetTokenAndRefresh(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/deviceCodeToToken":
w.Write([]byte(`{"state":true,"data":{"access_token":"at1","refresh_token":"rt1","expires_in":7200}}`))
case "/open/refreshToken":
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.PostFormValue("refresh_token") != "rt2" {
t.Errorf("bad refresh_token %q", r.PostFormValue("refresh_token"))
}
w.Write([]byte(`{"state":true,"data":{"access_token":"at2","refresh_token":"rt2","expires_in":7200}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
c := NewOpenClient("100195125", "", "")
token, err := c.GetToken(&QrCodeDataReturn{QrCodeData: QrCodeData{Uid: "U1"}, CodeVerifier: "v"})
if err != nil {
t.Fatalf("get token: %v", err)
}
if token.AccessToken != "at1" || c.AccessToken != "at1" {
t.Fatalf("bad token: %#v", token)
}
token, err = c.RefreshToken("rt2")
if err != nil {
t.Fatalf("refresh: %v", err)
}
if token.AccessToken != "at2" || c.RefreshTokenStr != "rt2" {
t.Fatalf("bad refresh result: %#v", token)
}
}
func TestRefreshTokenDead(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"state":false,"code":40140119,"message":"refresh_token 已过期"}`))
})
c := NewOpenClient("100195125", "at", "rt-dead")
_, err := c.RefreshToken("rt-dead")
if err == nil {
t.Fatal("expected error")
}
if !IsRefreshTokenDead(err) {
t.Fatalf("expected dead refresh token error, got %v", err)
}
if c.AccessToken != "" {
t.Fatalf("dead token should clear access token")
}
}
func TestFsListAndDownload(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/ufile/files":
if r.Header.Get("Authorization") != "Bearer at1" {
t.Errorf("missing auth header")
}
w.Write([]byte(`{"state":true,"path":[{"name":"根目录","cid":0}],"data":[
{"fid":"100","fc":"0","fn":"Movies","fs":0},
{"fid":"200","fc":"1","fn":"a.mkv","fs":123,"pc":"pickA"}]}`))
case "/open/ufile/downurl":
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.PostFormValue("pick_code") != "pickA" {
t.Errorf("bad pick_code")
}
w.Write([]byte(`{"state":true,"data":{"200":{"file_name":"a.mkv","url":{"url":"https://cdn/x.mkv"}}}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
c := NewOpenClient("100195125", "at1", "rt1")
files, pathStr, err := c.GetFsList(context.Background(), "0", 0, 100)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(files) != 2 {
t.Fatalf("want 2 files, got %d", len(files))
}
if !files[0].IsDir() {
t.Fatalf("first entry should be dir")
}
if pathStr != "根目录" {
t.Fatalf("path_str = %q", pathStr)
}
url, err := c.GetDownloadURL(context.Background(), "pickA")
if err != nil {
t.Fatalf("downurl: %v", err)
}
if url != "https://cdn/x.mkv" {
t.Fatalf("bad url %q", url)
}
}
func (f RemoteFile) IsDir() bool { return f.Category == TypeDir }
func TestGetFsDetailByCid(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/folder/get_info" {
t.Errorf("unexpected path %s", r.URL.Path)
}
w.Write([]byte(`{"state":true,"data":{"file_id":"100","file_name":"Movies","file_category":"0","size_byte":123}}`))
})
c := NewOpenClient("100195125", "at1", "rt1")
detail, err := c.GetFsDetailByCid(context.Background(), "100")
if err != nil {
t.Fatalf("detail: %v", err)
}
if detail.FileId != "100" || detail.FileName != "Movies" {
t.Fatalf("bad detail: %#v", detail)
}
}
// TestRelayRoundTrip 中继加解密往返 + 回调解析。
func TestRelayRoundTrip(t *testing.T) {
RelayEncryptionKey = "unit-test-shared-key"
defer func() { RelayEncryptionKey = "" }()
payload := `{"data":{"access_token":"at","refresh_token":"rt","expires_in":7200}}`
encrypted, err := EncryptRelay(payload)
if err != nil {
t.Fatal(err)
}
decrypted, err := DecryptRelay(encrypted)
if err != nil {
t.Fatal(err)
}
if decrypted != payload {
t.Fatalf("round trip mismatch: %q", decrypted)
}
provider, err := GetOAuthProvider(Source{SourceType: SourceTypeBuiltInRelay, Provider: ProviderQMediaSync, AppID: "QMediaSync"})
if err != nil {
t.Fatalf("relay provider should be available when key configured: %v", err)
}
token, err := provider.Confirm(context.Background(), map[string]string{"data": encrypted})
if err != nil {
t.Fatalf("confirm: %v", err)
}
if !token.Done || token.AccessToken != "at" || token.RefreshToken != "rt" {
t.Fatalf("bad token: %#v", token)
}
}
func TestRelayProviderRequiresKey(t *testing.T) {
RelayEncryptionKey = ""
if _, err := GetOAuthProvider(Source{SourceType: SourceTypeBuiltInRelay, Provider: ProviderQMediaSync, AppID: "QMediaSync"}); err == nil {
t.Fatal("relay provider should fail without key")
}
if RelayAvailable() {
t.Fatal("RelayAvailable should be false without key")
}
}
func TestMoviePilotProvider(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/u115/auth_url":
w.Write([]byte(`{"auth_url":"https://passport.115/auth?state=ST","state":"ST"}`))
case "/u115/token":
if r.URL.Query().Get("state") != "ST" {
t.Errorf("bad state")
}
w.Write([]byte(`{"state":true,"data":{"access_token":"at","refresh_token":"rt","expires_in":7200}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
provider := moviePilotOAuthProvider{authServer: srv.URL}
result, err := provider.BuildAuth(context.Background(), OAuthURLRequest{})
if err != nil {
t.Fatalf("build: %v", err)
}
if result.AuthURL == "" || result.State != "ST" || !result.Polling {
t.Fatalf("bad result: %#v", result)
}
token, err := provider.Poll(context.Background(), "ST")
if err != nil {
t.Fatalf("poll: %v", err)
}
if !token.Done || token.AccessToken != "at" {
t.Fatalf("bad token: %#v", token)
}
}
func TestCloudDriveBuildAuth(t *testing.T) {
provider := cloudDriveOAuthProvider{source: Source{SourceType: SourceTypeThirdPartyService, Provider: ProviderCloudDrive, AppID: "100195313"}}
result, err := provider.BuildAuth(context.Background(), OAuthURLRequest{
RedirectURL: "http://127.0.0.1:8080/api/strm/oauth/callback",
AuthorizationID: "auth-123",
})
if err != nil {
t.Fatalf("build: %v", err)
}
if !strings.Contains(result.AuthURL, "client_id=100195313") {
t.Fatalf("bad auth url: %s", result.AuthURL)
}
parsed, err := url.Parse(result.AuthURL)
if err != nil {
t.Fatalf("parse auth url: %v", err)
}
state, err := url.QueryUnescape(parsed.Query().Get("state"))
if err != nil {
t.Fatalf("unescape state: %v", err)
}
if !strings.Contains(state, "authorization_id=auth-123") {
t.Fatalf("missing auth id in state: %s", result.AuthURL)
}
token, err := provider.Confirm(context.Background(), map[string]string{
"access_token": "at", "refresh_token": "rt", "expires_in": "7200",
})
if err != nil {
t.Fatalf("confirm: %v", err)
}
if !token.Done || token.AccessToken != "at" {
t.Fatalf("bad token: %#v", token)
}
}
func TestSourceCatalog(t *testing.T) {
if len(BuiltInAppIDSources()) < 50 {
t.Fatalf("built-in app catalog too small: %d", len(BuiltInAppIDSources()))
}
if _, ok := FindSource(SourceTypeBuiltInAppID, ProviderOfficialPKCE, "100195125"); !ok {
t.Fatal("媒体播放器 app not found")
}
if len(BuiltInRelaySources()) == 0 || len(ThirdPartySources()) != 2 {
t.Fatal("relay/thrid-party sources broken")
}
}
+97
View File
@@ -0,0 +1,97 @@
// 115 开放平台常量与错误类型。
package cloud115
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
)
func sha256Sum(data []byte) [32]byte { return sha256.Sum256(data) }
func base64StdEncode(data []byte) string { return base64.StdEncoding.EncodeToString(data) }
// 115 开放平台 API 地址(变量便于测试注入 mock server)
var (
ProAPIBase = "https://proapi.115.com"
PassportAPIBase = "https://passportapi.115.com"
QRCodeAPIBase = "https://qrcodeapi.115.com"
FSPIsAPIBase = "https://fsapi.115.com"
)
const (
// 业务错误码
AccessTokenAuthFail = 40140126 // 访问过期,需刷新
AccessTokenExpiryCode = 40140125 // 访问过期,需刷新
AccessAuthInvalid = 40140124 // 访问无效,需刷新
RefreshTokenInvalid = 40140116 // 需重新授权
TokenRefreshFail = 40140121 // 刷新失败,可重试
RequestMaxLimitCode = 770004 // 访问频率过高
RequestRateLimitCode = 406 // 达到访问上限
// 刷新 token 的错误码
RefreshTokenFormatInvalid = 40140114
RefreshTokenSignInvalid = 40140115
RefreshTooFrequent = 40140117
RefreshTokenExpired = 40140119
RefreshTokenCheckFailed = 40140120
)
// DefaultUA 请求 UA。
const DefaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 MMTL-115-OpenAPI/1.0"
// OpenAPIError 保留 115 开放平台返回的原始错误信息。
type OpenAPIError struct {
Code int
Message string
}
func (e *OpenAPIError) Error() string {
if e.Code == 0 {
return fmt.Sprintf("115 接口错误:%s", e.Message)
}
return fmt.Sprintf("115 接口错误(%d):%s", e.Code, e.Message)
}
// NewOpenAPIResponseError 组装接口错误。
func NewOpenAPIResponseError(code, errno int, message, errorText, fallback string) error {
if code == 0 {
code = errno
}
if message == "" {
message = errorText
}
if code == 0 && message == "" {
return fmt.Errorf("%s", fallback)
}
return NewOpenAPIError(code, message)
}
// NewOpenAPIError 构造接口错误。
func NewOpenAPIError(code int, message string) *OpenAPIError {
if message == "" {
message = "未知错误"
}
return &OpenAPIError{Code: code, Message: message}
}
// IsRefreshTokenDead 判断 refresh_token 是否已无法继续使用。
func IsRefreshTokenDead(err error) bool {
if err == nil {
return false
}
var apiErr *OpenAPIError
if errors.As(err, &apiErr) {
switch apiErr.Code {
case RefreshTokenFormatInvalid, RefreshTokenSignInvalid, RefreshTokenInvalid, RefreshTokenExpired, RefreshTokenCheckFailed:
return true
}
}
return false
}
// genCodeChallenge 生成 PKCE code_challenge(sha256(codeVerifier) base64)。
func genCodeChallenge(codeVerifier string) string {
sum := sha256Sum([]byte(codeVerifier))
return base64StdEncode(sum[:])
}
+468
View File
@@ -0,0 +1,468 @@
// 115 OAuth 授权来源:内置应用(设备码扫码)、QMediaSync/MQFamily 中继
// (需共享 AES 密钥)、MoviePilot 轮询、CloudDrive 回跳。
// 逻辑移植自 QMediaSync 的 v115auth。
package cloud115
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// OAuthURLRequest 是发起授权的上下文。
type OAuthURLRequest struct {
Source Source
RedirectURL string // 本服务回调地址(中继/CloudDrive 回跳用)
AuthorizationID string
QRCode *QrCodeDataReturn `json:"-"` // 设备码模式:调用方预取的二维码
}
// OAuthURLResult 是授权发起结果。
type OAuthURLResult struct {
AuthURL string `json:"auth_url,omitempty"`
State string `json:"state,omitempty"`
Polling bool `json:"polling"`
ExpiresIn int64 `json:"expires_in,omitempty"`
// QRCode 非空表示设备码扫码模式(uid/time/sign/qrcode 一并返回)
QRCode *QrCodeDataReturn `json:"qrcode,omitempty"`
}
// OAuthTokenResult 是授权完成结果。
type OAuthTokenResult struct {
AccessToken string
RefreshToken string
ExpiresIn int64
Done bool
}
// OAuthProvider 描述一种 115 授权来源。
type OAuthProvider interface {
BuildAuth(ctx context.Context, req OAuthURLRequest) (OAuthURLResult, error)
Confirm(ctx context.Context, payload map[string]string) (OAuthTokenResult, error)
Poll(ctx context.Context, state string) (OAuthTokenResult, error)
}
var errUnsupportedOAuthOperation = errors.New("当前授权服务不支持此操作")
// GetOAuthProvider 按授权来源取 provider:
// - official_pkce:设备码扫码(需 AppID)
// - qmediasync/mqfamily:中继授权(需配置 strm.115_relay_key)
// - moviepilot / clouddrive:第三方服务
func GetOAuthProvider(source Source) (OAuthProvider, error) {
switch source.Provider {
case ProviderOfficialPKCE:
if strings.TrimSpace(source.AppID) == "" {
return nil, fmt.Errorf("缺少 115 开放平台应用 ID")
}
return deviceCodeOAuthProvider{}, nil
case ProviderQMediaSync, ProviderMQFamily:
if !RelayAvailable() {
return nil, fmt.Errorf("中继授权需要配置共享加密密钥(设置 strm.115_relay_key)")
}
return relayOAuthProvider{source: source}, nil
case ProviderMoviePilot:
return moviePilotOAuthProvider{authServer: oauthServerOr(source.AuthServer, "https://movie-pilot.org")}, nil
case ProviderCloudDrive:
return cloudDriveOAuthProvider{source: source}, nil
default:
return nil, fmt.Errorf("不支持的 115 授权来源")
}
}
func oauthServerOr(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimRight(strings.TrimSpace(value), "/")
}
// deviceCodeOAuthProvider 走 115 官方设备码扫码(无回调、无共享密钥)。
type deviceCodeOAuthProvider struct{}
func (deviceCodeOAuthProvider) BuildAuth(_ context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
if req.QRCode != nil {
return OAuthURLResult{QRCode: req.QRCode, ExpiresIn: 300}, nil
}
return OAuthURLResult{}, fmt.Errorf("缺少设备码数据")
}
func (deviceCodeOAuthProvider) Confirm(_ context.Context, _ map[string]string) (OAuthTokenResult, error) {
return OAuthTokenResult{}, errUnsupportedOAuthOperation
}
func (deviceCodeOAuthProvider) Poll(_ context.Context, _ string) (OAuthTokenResult, error) {
return OAuthTokenResult{}, errUnsupportedOAuthOperation
}
// relayOAuthProvider 走 QMediaSync/MQFamily 中继授权。
type relayOAuthProvider struct {
source Source
}
func (provider relayOAuthProvider) BuildAuth(_ context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
clientID := strings.TrimSpace(provider.source.AppID)
if clientID == "" {
clientID = BuiltInRelayQ115STRM
if provider.source.Provider == ProviderQMediaSync {
clientID = BuiltInRelayQMediaSync
}
}
redirectURL := strings.TrimSpace(req.RedirectURL)
if redirectURL != "" {
var err error
redirectURL, err = appendCallbackParams(redirectURL, url.Values{
"source": []string{"115"},
"authorization_id": []string{req.AuthorizationID},
})
if err != nil {
return OAuthURLResult{}, err
}
}
stateObj := struct {
State string `json:"state"`
Time int64 `json:"time"`
ClientId string `json:"client_id"`
RedirectURL string `json:"redirect_url"`
AuthorizationID string `json:"authorization_id,omitempty"`
}{
State: RandomString(16),
Time: time.Now().Unix(),
ClientId: clientID,
RedirectURL: redirectURL,
AuthorizationID: req.AuthorizationID,
}
stateJSON, _ := json.Marshal(stateObj)
stateEncoded, err := EncryptRelay(string(stateJSON))
if err != nil {
return OAuthURLResult{}, err
}
baseURL := oauthServerOr(provider.source.AuthServer, "https://api.mqfamily.top")
if provider.source.Provider == ProviderQMediaSync {
baseURL = oauthServerOr(provider.source.AuthServer, "https://oauth.qmediasync.cn")
}
return OAuthURLResult{AuthURL: fmt.Sprintf("%s/115.php?action=code&state=%s", baseURL, stateEncoded)}, nil
}
func (provider relayOAuthProvider) Confirm(_ context.Context, payload map[string]string) (OAuthTokenResult, error) {
data := payload["data"]
if data == "" {
return OAuthTokenResult{}, fmt.Errorf("缺少中转回调数据")
}
decryptedData, err := DecryptRelay(data)
if err != nil {
return OAuthTokenResult{}, err
}
var resp struct {
Data struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
} `json:"data"`
Error string `json:"error"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(decryptedData), &resp); err != nil {
return OAuthTokenResult{}, err
}
if resp.Data.AccessToken == "" || resp.Data.RefreshToken == "" {
if resp.Error != "" {
return OAuthTokenResult{}, errors.New(resp.Error)
}
if resp.Message != "" {
return OAuthTokenResult{}, errors.New(resp.Message)
}
return OAuthTokenResult{}, fmt.Errorf("中转回调未返回访问凭证")
}
return OAuthTokenResult{AccessToken: resp.Data.AccessToken, RefreshToken: resp.Data.RefreshToken, ExpiresIn: resp.Data.ExpiresIn, Done: true}, nil
}
func (provider relayOAuthProvider) Poll(_ context.Context, _ string) (OAuthTokenResult, error) {
return OAuthTokenResult{}, errUnsupportedOAuthOperation
}
// moviePilotOAuthProvider 走 MoviePilot 的 115 授权服务(轮询)。
type moviePilotOAuthProvider struct {
authServer string
}
func (provider moviePilotOAuthProvider) BuildAuth(ctx context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
endpoint := provider.authServer + "/u115/auth_url"
resp, err := httpGetJSON(ctx, endpoint)
if err != nil {
return OAuthURLResult{}, err
}
authURL := stringField(resp, "auth_url")
state := stringField(resp, "state")
if authURL == "" || state == "" {
return OAuthURLResult{}, fmt.Errorf("MoviePilot 授权服务响应缺少 auth_url 或 state")
}
return OAuthURLResult{AuthURL: authURL, State: state, Polling: true, ExpiresIn: 300}, nil
}
func (provider moviePilotOAuthProvider) Confirm(_ context.Context, _ map[string]string) (OAuthTokenResult, error) {
return OAuthTokenResult{}, errUnsupportedOAuthOperation
}
func (provider moviePilotOAuthProvider) Poll(ctx context.Context, state string) (OAuthTokenResult, error) {
if state == "" {
return OAuthTokenResult{}, fmt.Errorf("缺少授权状态")
}
endpoint := provider.authServer + "/u115/token?state=" + url.QueryEscape(state)
resp, err := httpGetJSON(ctx, endpoint)
if err != nil {
return OAuthTokenResult{}, err
}
return tokenResultFromMap(resp), nil
}
// cloudDriveOAuthProvider 走 CloudDrive 中转(115 授权页 → zhenyunpan 换 token → 回跳)。
type cloudDriveOAuthProvider struct {
source Source
}
func (provider cloudDriveOAuthProvider) BuildAuth(_ context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
if strings.TrimSpace(req.RedirectURL) == "" {
return OAuthURLResult{}, fmt.Errorf("CloudDrive 授权需要回跳地址")
}
callback, err := appendCallbackParams(req.RedirectURL, url.Values{
"source": []string{"115"},
"authorization_id": []string{req.AuthorizationID},
})
if err != nil {
return OAuthURLResult{}, err
}
clientID := strings.TrimSpace(provider.source.AppID)
if clientID == "" {
clientID = "100195313"
}
redirectURI := strings.TrimSpace(provider.source.RedirectURI)
if redirectURI == "" {
redirectURI = "https://redirect115.zhenyunpan.com"
}
authURL, _ := url.Parse("https://passportapi.115.com/open/authorize")
query := authURL.Query()
query.Set("client_id", clientID)
query.Set("redirect_uri", redirectURI)
query.Set("response_type", "code")
query.Set("state", callback)
authURL.RawQuery = query.Encode()
return OAuthURLResult{AuthURL: authURL.String(), ExpiresIn: 300}, nil
}
func (provider cloudDriveOAuthProvider) Confirm(_ context.Context, payload map[string]string) (OAuthTokenResult, error) {
expiresIn, _ := strconv.ParseInt(payload["expires_in"], 10, 64)
token := OAuthTokenResult{
AccessToken: payload["access_token"],
RefreshToken: payload["refresh_token"],
ExpiresIn: expiresIn,
}
token.Done = token.AccessToken != "" && token.RefreshToken != ""
if !token.Done {
return OAuthTokenResult{}, fmt.Errorf("CloudDrive 回调未返回访问凭证")
}
return token, nil
}
func (provider cloudDriveOAuthProvider) Poll(_ context.Context, _ string) (OAuthTokenResult, error) {
return OAuthTokenResult{}, errUnsupportedOAuthOperation
}
// ─── 工具 ──────────────────────────────────────────────────────────────────────
func appendCallbackParams(rawURL string, params url.Values) (string, error) {
callbackURL, err := url.Parse(rawURL)
if err != nil || callbackURL.Scheme == "" || callbackURL.Host == "" {
return "", fmt.Errorf("OAuth 回跳地址无效")
}
if callbackURL.Fragment != "" {
fragmentPath, fragmentQuery, hasQuery := strings.Cut(callbackURL.Fragment, "?")
fragmentValues := url.Values{}
if hasQuery {
fragmentValues, err = url.ParseQuery(fragmentQuery)
if err != nil {
return "", err
}
}
for key, values := range params {
for _, value := range values {
if value != "" {
fragmentValues.Set(key, value)
}
}
}
callbackURL.Fragment = fragmentPath + "?" + fragmentValues.Encode()
return callbackURL.String(), nil
}
query := callbackURL.Query()
for key, values := range params {
for _, value := range values {
if value != "" {
query.Set(key, value)
}
}
}
callbackURL.RawQuery = query.Encode()
return callbackURL.String(), nil
}
func httpGetJSON(ctx context.Context, endpoint string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", DefaultUA)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("授权服务返回 HTTP %d:%s", resp.StatusCode, string(body))
}
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
return nil, err
}
if nested, ok := data["data"].(map[string]any); ok {
for key, value := range nested {
if _, exists := data[key]; !exists {
data[key] = value
}
}
}
return data, nil
}
func tokenResultFromMap(data map[string]any) OAuthTokenResult {
if nested, ok := data["data"].(map[string]any); ok {
for key, value := range nested {
if _, exists := data[key]; !exists {
data[key] = value
}
}
}
expiresIn := int64(0)
switch value := data["expires_in"].(type) {
case float64:
expiresIn = int64(value)
case json.Number:
expiresIn, _ = value.Int64()
case string:
expiresIn, _ = strconv.ParseInt(value, 10, 64)
}
token := OAuthTokenResult{
AccessToken: stringField(data, "access_token"),
RefreshToken: stringField(data, "refresh_token"),
ExpiresIn: expiresIn,
}
token.Done = token.AccessToken != "" && token.RefreshToken != ""
return token
}
func stringField(data map[string]any, key string) string {
value, ok := data[key]
if !ok || value == nil {
return ""
}
switch v := value.(type) {
case string:
return v
case fmt.Stringer:
return v.String()
default:
return fmt.Sprint(v)
}
}
// ─── 中继回调 AES 加解密(与 QMediaSync 同算法:AES-256-CBC + URL-safe Base64) ───
// EncryptRelay 加密中继授权 state / 回调数据。
func EncryptRelay(plaintext string) (string, error) {
return encryptAES(plaintext, RelayEncryptionKey)
}
// DecryptRelay 解密中继回调数据。
func DecryptRelay(encrypted string) (string, error) {
return decryptAES(encrypted, RelayEncryptionKey)
}
func encryptAES(plaintext, keyText string) (string, error) {
if keyText == "" {
return "", errors.New("加密密钥不能为空(请配置 strm.115_relay_key)")
}
keyHash := sha256.Sum256([]byte(keyText))
block, err := aes.NewCipher(keyHash[:])
if err != nil {
return "", err
}
padding := aes.BlockSize - len(plaintext)%aes.BlockSize
padtext := append([]byte(plaintext), make([]byte, padding)...)
for i := 0; i < padding; i++ {
padtext[len(plaintext)+i] = byte(padding)
}
ciphertext := make([]byte, aes.BlockSize+len(padtext))
iv := ciphertext[:aes.BlockSize]
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return "", err
}
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext[aes.BlockSize:], padtext)
encoded := base64.StdEncoding.EncodeToString(ciphertext)
encoded = strings.ReplaceAll(encoded, "+", "-")
encoded = strings.ReplaceAll(encoded, "/", "_")
return strings.TrimRight(encoded, "="), nil
}
func decryptAES(encrypted, keyText string) (string, error) {
if keyText == "" {
return "", errors.New("加密密钥不能为空(请配置 strm.115_relay_key)")
}
encrypted = strings.ReplaceAll(encrypted, "-", "+")
encrypted = strings.ReplaceAll(encrypted, "_", "/")
switch len(encrypted) % 4 {
case 2:
encrypted += "=="
case 3:
encrypted += "="
}
data, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
return "", err
}
if len(data) < aes.BlockSize {
return "", errors.New("密文长度不足")
}
keyHash := sha256.Sum256([]byte(keyText))
block, err := aes.NewCipher(keyHash[:])
if err != nil {
return "", err
}
iv := data[:aes.BlockSize]
ciphertext := data[aes.BlockSize:]
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(ciphertext, ciphertext)
if len(ciphertext) == 0 {
return "", errors.New("解密结果为空")
}
padding := int(ciphertext[len(ciphertext)-1])
if padding <= 0 || padding > aes.BlockSize {
return "", errors.New("解密填充非法")
}
return string(ciphertext[:len(ciphertext)-padding]), nil
}
+326
View File
@@ -0,0 +1,326 @@
// 115 开放平台只读 API:列目录、详情、下载直链、授权(二维码/换 token/刷新)。
package cloud115
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// ─── 文件模型 ──────────────────────────────────────────────────────────────────
type FileType string
const (
TypeFile FileType = "1"
TypeDir FileType = "0"
)
// RemoteFile 是 115 文件列表中的一个条目。
type RemoteFile struct {
FileId string `json:"fid"` // 文件 ID
Pid string `json:"pid"` // 父文件夹 ID
Category FileType `json:"fc"` // 0 文件夹 1 文件
FileName string `json:"fn"` // 文件名
PickCode string `json:"pc"` // 提取码
Utime int64 `json:"upt"` // 修改时间
Ptime int64 `json:"uppt"` // 上传时间
Sha1 string `json:"sha1"` // SHA1
FileSize int64 `json:"fs"` // 大小
Fta string `json:"fta"` // 0/2 未上传完成,1 已完成
}
func (f RemoteFile) ModifiedAt() int64 {
if f.Utime > 0 {
return f.Utime
}
return f.Ptime
}
type FileListResp struct {
RespBase
Path []struct {
Name string `json:"name"`
FileId string `json:"cid"`
} `json:"path"`
PathStr string `json:"path_str"`
}
// GetFsList 列目录(cid=0 为根目录)。
func (c *OpenClient) GetFsList(ctx context.Context, cid string, offset, limit int) ([]RemoteFile, string, error) {
if cid == "" {
cid = "0"
}
params := map[string]string{"cid": cid}
if limit > 0 {
params["limit"] = fmt.Sprint(limit)
}
if offset > 0 {
params["offset"] = fmt.Sprint(offset)
}
params["cur"] = "1"
params["show_dir"] = "1"
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/ufile/files", params, 2)
if err != nil {
return nil, "", err
}
// state=false(token 过期、限流、业务失败等)绝不能当作空目录返回:
// 同步流程会据此认为远端已清空并清理本地 .strm/元数据文件。
if !resp.State {
return nil, "", NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 接口调用失败")
}
var list FileListResp
list.RespBase = *resp
if len(resp.Raw) > 0 {
_ = json.Unmarshal(resp.Raw, &list)
}
files, err := openList[RemoteFile](resp.Data)
if err != nil {
return nil, "", fmt.Errorf("115: 解析文件列表失败:%w", err)
}
pathStr := make([]string, 0, len(list.Path))
for _, item := range list.Path {
if item.FileId == "0" {
continue
}
pathStr = append(pathStr, item.Name)
}
return files, strings.Join(pathStr, "/"), nil
}
// GetFsDetailByCid 查询文件(夹)详情。
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
params := map[string]string{"file_id": fileId}
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/folder/get_info", params, 2)
if err != nil {
return nil, err
}
return openFirstList[RemoteFileDetail](resp.Data)
}
// RemoteFileDetail 是文件详情。
type RemoteFileDetail struct {
FileId string `json:"file_id"`
FileName string `json:"file_name"`
PickCode string `json:"pick_code"`
Sha1 string `json:"sha1"`
Category FileType `json:"file_category"`
SizeByte int64 `json:"size_byte"`
Paths []struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
} `json:"paths"`
}
// ─── 下载直链 ──────────────────────────────────────────────────────────────────
type downloadURLData struct {
FileName string `json:"file_name"`
PickCode string `json:"pick_code"`
Sha1 string `json:"sha1"`
URL struct {
URL string `json:"url"`
} `json:"url"`
}
// GetDownloadURL 获取下载直链(pickcode)。
func (c *OpenClient) GetDownloadURL(ctx context.Context, pickCode string) (string, error) {
params := map[string]string{"pick_code": pickCode}
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1)
if err != nil {
return "", err
}
var data map[string]downloadURLData
if err := json.Unmarshal(resp.Data, &data); err != nil {
return "", fmt.Errorf("115: 解析下载地址失败:%w", err)
}
first := firstOrEmpty(data)
if first.URL.URL == "" {
return "", fmt.Errorf("115: 下载地址为空(文件可能未上传完成或已被删除)")
}
return first.URL.URL, nil
}
// ─── 授权(设备码扫码) ──────────────────────────────────────────────────────
// QrCodeScanStatus 扫码状态。
type QrCodeScanStatus int
const (
QrCodeScanStatusExpired QrCodeScanStatus = 5
QrCodeScanStatusNotScanned QrCodeScanStatus = 2
QrCodeScanStatusScanned QrCodeScanStatus = 3
QrCodeScanStatusConfirmed QrCodeScanStatus = 4
)
func (s QrCodeScanStatus) String() string {
switch s {
case QrCodeScanStatusNotScanned:
return "waiting"
case QrCodeScanStatusScanned:
return "scanned"
case QrCodeScanStatusConfirmed:
return "confirmed"
default:
return "expired"
}
}
func (s QrCodeScanStatus) Tip() string {
switch s {
case QrCodeScanStatusNotScanned:
return "等待扫码"
case QrCodeScanStatusScanned:
return "已扫码,请在 115 客户端确认"
case QrCodeScanStatusConfirmed:
return "授权成功"
default:
return "二维码已过期"
}
}
// QrCodeData 是设备码二维码数据。
type QrCodeData struct {
Uid string `json:"uid"`
Time int64 `json:"time"`
Sign string `json:"sign"`
Qrcode string `json:"qrcode"` // 二维码图片 URL
}
// QrCodeDataReturn 含 PKCE code_verifier。
type QrCodeDataReturn struct {
QrCodeData
CodeVerifier string `json:"code_verifier"`
}
// TokenData 是 115 访问令牌。
type TokenData struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
}
// GetQrCode 获取设备码登录二维码。
func (c *OpenClient) GetQrCode() (*QrCodeDataReturn, error) {
codeVerifier := RandomString(64)
params := map[string]string{
"client_id": c.AppID,
"code_challenge": genCodeChallenge(codeVerifier),
"code_challenge_method": "sha256",
}
resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/authDeviceCode", params, false, 1)
if err != nil {
return nil, err
}
code, err := openFirstList[QrCodeData](resp.Data)
if err != nil {
return nil, err
}
return &QrCodeDataReturn{QrCodeData: *code, CodeVerifier: codeVerifier}, nil
}
// QrCodeScanStatus 查询扫码状态。
func (c *OpenClient) QrCodeScanStatus(codeData *QrCodeData) (QrCodeScanStatus, error) {
if codeData == nil {
return QrCodeScanStatusExpired, fmt.Errorf("空二维码数据")
}
params := map[string]string{
"uid": codeData.Uid,
"time": fmt.Sprint(codeData.Time),
"sign": codeData.Sign,
}
resp, err := c.doJSON(context.Background(), "GET", QRCodeAPIBase+"/get/status/", params, false, 1)
if err != nil {
return QrCodeScanStatusExpired, err
}
status, err := openFirstList[struct {
Status int `json:"status"` // 0 未扫码 1 已扫码 2 已确认
}](resp.Data)
if err != nil {
return QrCodeScanStatusExpired, err
}
switch status.Status {
case 1:
return QrCodeScanStatusScanned, nil
case 2:
return QrCodeScanStatusConfirmed, nil
case 0:
return QrCodeScanStatusNotScanned, nil
default:
return QrCodeScanStatusExpired, nil
}
}
// GetToken 用设备码换访问令牌。
func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
if qrCode == nil || qrCode.Uid == "" {
return nil, fmt.Errorf("空二维码数据")
}
params := map[string]string{
"uid": qrCode.Uid,
"code_verifier": qrCode.CodeVerifier,
}
resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/deviceCodeToToken", params, false, 0)
if err != nil {
return nil, err
}
token, err := openFirstList[TokenData](resp.Data)
if err != nil {
return nil, err
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return token, nil
}
// RefreshToken 刷新访问令牌。
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
if refreshToken == "" {
refreshToken = c.RefreshTokenStr
}
if refreshToken == "" {
return nil, fmt.Errorf("没有可用的 refresh_token")
}
params := map[string]string{"refresh_token": refreshToken}
resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/refreshToken", params, false, 0)
if err != nil && resp == nil {
return nil, err
}
if resp == nil {
return nil, err
}
if !resp.State {
apiErr := NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 开放平台刷新访问凭证失败")
if IsRefreshTokenDead(apiErr) {
c.SetAuthToken("", "")
}
return nil, apiErr
}
token, err := openFirstList[TokenData](resp.Data)
if err != nil {
return nil, err
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return token, nil
}
// ─── 用户信息 ──────────────────────────────────────────────────────────────────
// UserInfo 是 115 用户信息。
type UserInfo struct {
UserId json.Number `json:"user_id"`
UserName string `json:"user_name"`
}
// FetchUserInfo 获取用户信息。
func (c *OpenClient) FetchUserInfo(ctx context.Context) (*UserInfo, error) {
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/user/info", nil, 1)
if err != nil {
return nil, err
}
var info UserInfo
if err := json.Unmarshal(resp.Data, &info); err != nil {
return nil, fmt.Errorf("115: 解析用户信息失败:%w", err)
}
return &info, nil
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
package cloud115
import "math/rand"
const randCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// RandomString 生成指定长度的随机字符串(PKCE code_verifier 等)。
func RandomString(length int) string {
b := make([]byte, length)
for i := range b {
b[i] = randCharset[rand.Intn(len(randCharset))]
}
return string(b)
}