mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 20:26:36 +08:00
优化,排查项目问题
This commit is contained in:
@@ -8,6 +8,8 @@ import (
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint.
|
||||
@@ -22,11 +24,18 @@ type cloudDrive2Provider struct {
|
||||
base *url.URL
|
||||
username string
|
||||
password string
|
||||
token string
|
||||
token string // 配置的静态令牌(构造后只读)
|
||||
ua string
|
||||
apiBase *url.URL
|
||||
client *http.Client
|
||||
proxy bool
|
||||
|
||||
// tokenMu / loginToken / loginTokenSeen 保护 OpenList 用户名密码登录的
|
||||
// token 缓存:多 worker 并发时单飞登录,缓存有效期内直接复用,
|
||||
// 401 时清缓存重登(见 clouddrive2_openlist.go 的 openListAPIToken)。
|
||||
tokenMu sync.Mutex
|
||||
loginToken string
|
||||
loginTokenSeen time.Time
|
||||
}
|
||||
|
||||
func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
|
||||
|
||||
@@ -36,9 +36,10 @@ func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, p.decorateDAVStatusError(resp, target)
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
// 流式解码:超大目录(如上万条目的网盘目录)响应可能远超旧 4MB 截断上限,
|
||||
// 直接 xml.Unmarshal 会截断报错;这里用 LimitReader(64MB) + Decoder 边读边解
|
||||
var multi cloudDAVMultiStatus
|
||||
if err := xml.Unmarshal(body, &multi); err != nil {
|
||||
if err := xml.NewDecoder(io.LimitReader(resp.Body, 64<<20)).Decode(&multi); err != nil {
|
||||
return nil, fmt.Errorf("%s: decode webdav: %w", p.name, err)
|
||||
}
|
||||
basePath := strings.TrimRight(p.base.EscapedPath(), "/")
|
||||
|
||||
@@ -131,10 +131,13 @@ func (p *cloudDrive2Provider) openListAPIMove(ctx context.Context, source, targe
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) openListAPIPost(ctx context.Context, apiPath string, payload any, action string) error {
|
||||
token, err := p.openListAPIToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := doWithOpenListAPIToken(ctx, p, func(token string) (struct{}, error) {
|
||||
return struct{}{}, p.openListAPIPostWithToken(ctx, apiPath, payload, action, token)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) openListAPIPostWithToken(ctx context.Context, apiPath string, payload any, action, token string) error {
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL(apiPath), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -151,6 +154,9 @@ func (p *cloudDrive2Provider) openListAPIPost(ctx context.Context, apiPath strin
|
||||
return decorateDAVTransportError(p.name, p.openListAPIURL(apiPath), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return errOpenListAPITokenExpired
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("%s: api %s returned http %d", p.name, action, resp.StatusCode)
|
||||
}
|
||||
@@ -200,27 +206,43 @@ func (p *cloudDrive2Provider) PutFile(ctx context.Context, remotePath string, r
|
||||
}
|
||||
|
||||
// openListAPIPutFile 通过 OpenList /api/fs/form 上传(QMediaSync 同款契约:
|
||||
// PUT + multipart + File-Path 头)。
|
||||
// PUT + multipart + File-Path 头)。使用 io.Pipe + multipart.Writer 边写边发,
|
||||
// 避免把整个文件读进内存。
|
||||
func (p *cloudDrive2Provider) openListAPIPutFile(ctx context.Context, remotePath string, r io.Reader) error {
|
||||
token, err := p.openListAPIToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encodedPath := openListPathEscape(remotePath)
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
formFile, err := writer.CreateFormFile("file", path.Base(remotePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(formFile, r); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.openListAPIURL("/api/fs/form"), body)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
writer := multipart.NewWriter(pw)
|
||||
go func() {
|
||||
var writeErr error
|
||||
defer func() {
|
||||
// 读源失败必须传给 pipe 写端,让 HTTP 请求以失败收场而不是静默截断
|
||||
if writeErr != nil {
|
||||
_ = pw.CloseWithError(writeErr)
|
||||
return
|
||||
}
|
||||
_ = pw.Close()
|
||||
}()
|
||||
formFile, err := writer.CreateFormFile("file", path.Base(remotePath))
|
||||
if err != nil {
|
||||
writeErr = err
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(formFile, r); err != nil {
|
||||
writeErr = err
|
||||
return
|
||||
}
|
||||
writeErr = writer.Close()
|
||||
}()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.openListAPIURL("/api/fs/form"), pr)
|
||||
if err != nil {
|
||||
// 关闭读端以释放仍在等待写入的后台 goroutine(其 Write 会立即失败返回)
|
||||
_ = pr.Close()
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", token)
|
||||
@@ -230,9 +252,15 @@ func (p *cloudDrive2Provider) openListAPIPutFile(ctx context.Context, remotePath
|
||||
req.Header.Set("Overwrite", "true")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
// 传输层失败(含提前断开)时 net/http 会关闭请求 body,解除后台 goroutine 阻塞
|
||||
return decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/form"), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// 流式 body 无法重放,不能自动重试:清除登录 token 缓存让下次上传重新登录,
|
||||
// 本次返回明确错误交由调用方重试
|
||||
p.invalidateOpenListAPIToken()
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return p.openListAPIStatusError("upload", remotePath, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -4,18 +4,50 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) {
|
||||
// errOpenListAPITokenExpired 标记 OpenList 返回 401(登录 token 已失效):
|
||||
// 调用方收到后应清缓存重登一次再重试原请求。
|
||||
var errOpenListAPITokenExpired = errors.New("openlist api token expired")
|
||||
|
||||
// openListAPITokenCacheTTL 登录 token 缓存有效期(OpenList 默认签发 48h JWT,
|
||||
// 这里保守取 30 分钟,过期自动重新登录)。
|
||||
const openListAPITokenCacheTTL = 30 * time.Minute
|
||||
|
||||
// doWithOpenListAPIToken 获取 OpenList API token 后执行 fn;若请求命中 401
|
||||
// (登录 token 失效)则清缓存重登一次并重试,避免一次 token 轮换导致整批请求失败。
|
||||
func doWithOpenListAPIToken[T any](ctx context.Context, p *cloudDrive2Provider, fn func(token string) (T, error)) (T, error) {
|
||||
var zero T
|
||||
token, err := p.openListAPIToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return zero, err
|
||||
}
|
||||
result, err := fn(token)
|
||||
if err == nil || !errors.Is(err, errOpenListAPITokenExpired) {
|
||||
return result, err
|
||||
}
|
||||
p.invalidateOpenListAPIToken()
|
||||
token, err = p.openListAPIToken(ctx)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return fn(token)
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) {
|
||||
return doWithOpenListAPIToken(ctx, p, func(token string) ([]FileEntry, error) {
|
||||
return p.listOpenListAPIWithToken(ctx, dir, token)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) listOpenListAPIWithToken(ctx context.Context, dir, token string) ([]FileEntry, error) {
|
||||
const pageSize = 500
|
||||
target := normalizeCloudDAVPath(dir)
|
||||
out := make([]FileEntry, 0, pageSize)
|
||||
@@ -45,6 +77,9 @@ func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) (
|
||||
var decoded openListListResponse
|
||||
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&decoded)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, errOpenListAPITokenExpired
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, p.openListAPIStatusError("list", target, resp.StatusCode)
|
||||
}
|
||||
@@ -85,10 +120,12 @@ func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) (
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
|
||||
token, err := p.openListAPIToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doWithOpenListAPIToken(ctx, p, func(token string) (*DirectLink, error) {
|
||||
return p.resolveOpenListAPIDirectWithToken(ctx, fileRef, token)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) resolveOpenListAPIDirectWithToken(ctx context.Context, fileRef, token string) (*DirectLink, error) {
|
||||
payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
@@ -105,6 +142,9 @@ func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, file
|
||||
return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/get"), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, errOpenListAPITokenExpired
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, p.openListAPIStatusError("get", fileRef, resp.StatusCode)
|
||||
}
|
||||
@@ -163,6 +203,10 @@ func (p *cloudDrive2Provider) hasOpenListAPICredentials() bool {
|
||||
return strings.TrimSpace(p.token) != "" || (strings.TrimSpace(p.username) != "" && p.password != "")
|
||||
}
|
||||
|
||||
// openListAPIToken 返回 OpenList API 访问令牌:
|
||||
// - 配置了静态 token 时直接使用(构造后只读,无并发问题);
|
||||
// - 否则用用户名密码登录,并在缓存有效期内单飞复用——8 个同步 worker 并发时
|
||||
// 只会有一个 goroutine 真正执行登录,避免登录风暴;登录 token 的写入受 tokenMu 保护。
|
||||
func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, error) {
|
||||
if token := strings.TrimSpace(p.token); token != "" {
|
||||
return token, nil
|
||||
@@ -170,6 +214,30 @@ func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, err
|
||||
if strings.TrimSpace(p.username) == "" || p.password == "" {
|
||||
return "", nil
|
||||
}
|
||||
p.tokenMu.Lock()
|
||||
defer p.tokenMu.Unlock()
|
||||
if p.loginToken != "" && time.Since(p.loginTokenSeen) < openListAPITokenCacheTTL {
|
||||
return p.loginToken, nil
|
||||
}
|
||||
token, err := p.openListAPILogin(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.loginToken = token
|
||||
p.loginTokenSeen = time.Now()
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// invalidateOpenListAPIToken 清除登录 token 缓存(收到 401 时调用,下次请求重新登录)。
|
||||
func (p *cloudDrive2Provider) invalidateOpenListAPIToken() {
|
||||
p.tokenMu.Lock()
|
||||
p.loginToken = ""
|
||||
p.loginTokenSeen = time.Time{}
|
||||
p.tokenMu.Unlock()
|
||||
}
|
||||
|
||||
// openListAPILogin 调用 OpenList /api/auth/login 换取登录 token。
|
||||
func (p *cloudDrive2Provider) openListAPILogin(ctx context.Context) (string, error) {
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"username": p.username,
|
||||
"password": p.password,
|
||||
@@ -204,7 +272,6 @@ func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, err
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("%s: api login returned empty token", p.name)
|
||||
}
|
||||
p.token = token
|
||||
return token, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ func (p *openAPI115Provider) Ping(ctx context.Context) error {
|
||||
if strings.TrimSpace(p.c.AppID) == "" {
|
||||
return fmt.Errorf("115: 缺少开放平台应用 ID,请重新授权")
|
||||
}
|
||||
if strings.TrimSpace(p.c.AccessToken) == "" {
|
||||
if strings.TrimSpace(p.c.CurrentAccessToken()) == "" {
|
||||
return fmt.Errorf("115: 缺少访问令牌,请重新授权")
|
||||
}
|
||||
_, _, err := p.c.GetFsList(ctx, "0", 0, 1)
|
||||
@@ -70,7 +70,7 @@ func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntr
|
||||
Name: f.FileName,
|
||||
IsDir: f.Category == cloud115.TypeDir,
|
||||
Size: f.FileSize,
|
||||
MTime: f.Utime,
|
||||
MTime: f.ModifiedAt(),
|
||||
PickCode: f.PickCode,
|
||||
})
|
||||
}
|
||||
@@ -127,12 +127,15 @@ func (p *openAPI115Provider) PutFileNamed(ctx context.Context, parentCID, fileNa
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("115: 关闭临时文件失败:%w", err)
|
||||
}
|
||||
// 重命名为目标文件名,保证上传到 115 后保留原始文件名
|
||||
// 重命名为目标文件名,保证上传到 115 后保留原始文件名。
|
||||
// 重命名失败必须 fail fast:静默用随机临时名上传会导致 115 上的文件名
|
||||
// 变成 mebox-upload-xxx,破坏元数据文件名契约。
|
||||
if fileName != "" && fileName != filepath.Base(tmpPath) {
|
||||
namedPath := filepath.Join(filepath.Dir(tmpPath), fileName)
|
||||
if err := os.Rename(tmpPath, namedPath); err == nil {
|
||||
tmpPath = namedPath
|
||||
if err := os.Rename(tmpPath, namedPath); err != nil {
|
||||
return fmt.Errorf("115: 重命名临时文件为 %s 失败:%w", fileName, err)
|
||||
}
|
||||
tmpPath = namedPath
|
||||
}
|
||||
_, err = p.c.Upload(ctx, tmpPath, parentCID, "", "")
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user