Files
2026-07-27 19:31:43 +08:00

426 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package onedrive
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"litepan/internal/domain"
"litepan/internal/driver"
"litepan/internal/driver/uploadutil"
"litepan/internal/httpx"
)
const (
simpleUploadLimit = int64(4 * 1024 * 1024)
uploadChunkSize = int64(10 * 1024 * 1024)
uploadAttempts = 3
)
type oneDriveResumeCtx struct {
parentID string
requestedName string
targetName string
fileSize int64
uploadURL string
uploadedBytes int64
fileID string
finalName string
}
func (d *Driver) UploadLocalFile(ctx context.Context, req driver.LocalUploadRequest) (*driver.LocalUploadResult, error) {
requestedName := filepath.Base(strings.TrimSpace(req.FileName))
if requestedName == "" || requestedName == "." {
return nil, domain.Errorf(domain.CodeValidation, "上传文件名不能为空")
}
if err := uploadutil.ValidateFileName(requestedName); err != nil {
return nil, err
}
local, err := uploadutil.StatLocalFile(req.LocalPath)
if err != nil {
return nil, err
}
parentID := d.normalizeParent(req.ParentID)
policy := uploadutil.NormalizeConflictPolicy(req.ConflictPolicy)
resume := normalizeOneDriveResumeState(req.ResumeState, parentID, requestedName, local.Size)
fileName := requestedName
if resume != nil {
fileName = resume.targetName
} else {
var skipped bool
var existingID string
fileName, skipped, existingID, err = d.prepareUploadName(ctx, parentID, requestedName, policy)
if err != nil {
return nil, err
}
if skipped {
return &driver.LocalUploadResult{
FileID: existingID, ParentID: parentID, FileName: fileName, Size: local.Size, Skipped: true,
Message: fmt.Sprintf("文件 '%s' 已存在,已跳过", fileName),
}, nil
}
}
if resume != nil {
uploadutil.NotifyProgress(req.OnProgress, resume.uploadedBytes, local.Size, "正在继续上传到 OneDrive")
} else {
uploadutil.NotifyProgress(req.OnProgress, 0, local.Size, "正在准备上传到 OneDrive")
}
var item graphItem
if local.Size <= simpleUploadLimit {
item, err = d.uploadSmall(ctx, local.Path, parentID, fileName, local.Size, graphConflictBehavior(policy), req.OnProgress)
} else {
item, err = d.uploadLarge(ctx, local.Path, parentID, requestedName, fileName, local.Size, graphConflictBehavior(policy), resume, req.OnProgress, req.OnResumeState)
}
if err != nil {
return nil, err
}
if item.ID == "" {
return nil, domain.Errorf(domain.CodeDriverError, "OneDrive 上传完成但未返回文件 id")
}
finalName := strings.TrimSpace(item.Name)
if finalName == "" {
finalName = fileName
}
uploadutil.NotifyProgress(req.OnProgress, local.Size, local.Size, "上传成功")
return &driver.LocalUploadResult{
FileID: item.ID, ParentID: parentID, FileName: finalName, Size: local.Size,
Message: fmt.Sprintf("文件 '%s' 上传成功", finalName),
}, nil
}
func normalizeOneDriveResumeState(state map[string]any, parentID, requestedName string, fileSize int64) *oneDriveResumeCtx {
if fileSize <= simpleUploadLimit || len(state) == 0 ||
strings.TrimSpace(uploadutil.AnyString(state["parent_id"])) != parentID ||
strings.TrimSpace(uploadutil.AnyString(state["requested_name"])) != requestedName {
return nil
}
resumeSize, ok := uploadutil.MapInt64(state["file_size"])
targetName := strings.TrimSpace(uploadutil.AnyString(state["target_name"]))
uploadURL := strings.TrimSpace(uploadutil.AnyString(state["upload_url"]))
fileID := strings.TrimSpace(uploadutil.AnyString(state["file_id"]))
if !ok || resumeSize != fileSize || targetName == "" || (uploadURL == "" && fileID == "") {
return nil
}
uploaded := uploadutil.ResumeStateUploadedBytes(state)
if uploaded < 0 || uploaded > fileSize {
uploaded = 0
}
return &oneDriveResumeCtx{
parentID: parentID,
requestedName: requestedName,
targetName: targetName,
fileSize: fileSize,
uploadURL: uploadURL,
uploadedBytes: uploaded,
fileID: fileID,
finalName: strings.TrimSpace(uploadutil.AnyString(state["final_name"])),
}
}
func persistOneDriveResumeState(onState driver.UploadStateCallback, resume *oneDriveResumeCtx) {
if onState == nil || resume == nil {
return
}
progress := int(resume.uploadedBytes * 100 / uploadutil.Max64(resume.fileSize, 1))
if resume.uploadedBytes < resume.fileSize && progress > 99 {
progress = 99
}
onState(map[string]any{
"parent_id": resume.parentID,
"requested_name": resume.requestedName,
"target_name": resume.targetName,
"file_size": resume.fileSize,
"upload_url": resume.uploadURL,
"uploaded_bytes": resume.uploadedBytes,
"progress": progress,
"file_id": resume.fileID,
"final_name": resume.finalName,
})
}
func (d *Driver) prepareUploadName(ctx context.Context, parentID, fileName, policy string) (string, bool, string, error) {
if policy == "overwrite" {
return fileName, false, "", nil
}
items, err := d.ListFiles(ctx, parentID)
if err != nil {
return "", false, "", err
}
existing := make(map[string]struct{}, len(items))
for _, item := range items {
existing[item.Name] = struct{}{}
if item.Name != fileName {
continue
}
if item.IsDir {
return "", false, "", domain.Errorf(domain.CodeValidation, "目标目录已存在同名文件夹 '%s'", fileName)
}
switch policy {
case "skip":
return fileName, true, item.ID, nil
case "fail":
return "", false, "", domain.Errorf(domain.CodeValidation, "目标目录已存在同名文件 '%s'", fileName)
case "rename", "keep_both", "keep_both_new":
return uploadutil.KeepBothName(fileName, existing), false, "", nil
}
}
return fileName, false, "", nil
}
func graphConflictBehavior(policy string) string {
switch policy {
case "rename", "keep_both", "keep_both_new":
return "rename"
case "skip", "fail":
return "fail"
default:
return "replace"
}
}
func (d *Driver) uploadSmall(ctx context.Context, localPath, parentID, fileName string, size int64, conflict string, progress driver.UploadProgress) (graphItem, error) {
return d.uploadSmallOnce(ctx, localPath, parentID, fileName, size, conflict, progress, false)
}
func (d *Driver) uploadSmallOnce(ctx context.Context, localPath, parentID, fileName string, size int64, conflict string, progress driver.UploadProgress, retried bool) (graphItem, error) {
path := d.uploadItemURL(parentID, fileName, "/content")
query := url.Values{"@microsoft.graph.conflictBehavior": []string{conflict}}
file, err := os.Open(localPath)
if err != nil {
return graphItem{}, domain.Wrap(domain.CodeDriverError, err)
}
defer file.Close()
if err := d.waitOperationDelay(ctx); err != nil {
return graphItem{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, graphURL(path)+"?"+query.Encode(), file)
if err != nil {
return graphItem{}, domain.Wrap(domain.CodeInternal, err)
}
req.Header.Set("Authorization", "Bearer "+d.currentToken())
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Length", fmt.Sprintf("%d", size))
req.ContentLength = size
uploadutil.NotifyProgress(progress, 0, size, "正在上传到 OneDrive(1/1)")
resp, data, err := httpx.Execute(d.client, req, httpx.DefaultReadLimit)
if err != nil {
return graphItem{}, domain.Wrap(domain.CodeDriverError, err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
err := mapGraphError(resp.StatusCode, data)
if retried || !isAuthError(err) {
return graphItem{}, err
}
if _, refreshErr := d.doRefresh(ctx); refreshErr != nil {
return graphItem{}, refreshErr
}
return d.uploadSmallOnce(ctx, localPath, parentID, fileName, size, conflict, progress, true)
}
var item graphItem
if err := json.Unmarshal(data, &item); err != nil {
return graphItem{}, domain.Errorf(domain.CodeDriverError, "OneDrive 上传响应格式异常:%s", httpx.Truncate(data, 300))
}
return item, nil
}
func (d *Driver) uploadLarge(
ctx context.Context,
localPath, parentID, requestedName, fileName string,
size int64,
conflict string,
resume *oneDriveResumeCtx,
progress driver.UploadProgress,
onState driver.UploadStateCallback,
) (graphItem, error) {
if resume != nil && resume.fileID != "" {
return graphItem{ID: resume.fileID, Name: resume.finalName, Size: size}, nil
}
if resume == nil {
var session uploadSession
if err := d.apiRequest(ctx, http.MethodPost, d.uploadItemURL(parentID, fileName, "/createUploadSession"), nil, map[string]any{
"item": map[string]string{"@microsoft.graph.conflictBehavior": conflict},
}, &session); err != nil {
return graphItem{}, err
}
if strings.TrimSpace(session.UploadURL) == "" {
return graphItem{}, domain.Errorf(domain.CodeDriverError, "OneDrive 未返回上传会话地址")
}
resume = &oneDriveResumeCtx{
parentID: parentID,
requestedName: requestedName,
targetName: fileName,
fileSize: size,
uploadURL: strings.TrimSpace(session.UploadURL),
}
persistOneDriveResumeState(onState, resume)
} else {
session, err := d.getUploadSession(ctx, resume.uploadURL)
if err != nil {
return graphItem{}, err
}
next, err := oneDriveNextOffset(session.NextExpectedRange, size)
if err != nil {
return graphItem{}, err
}
resume.uploadedBytes = next
persistOneDriveResumeState(onState, resume)
uploadutil.NotifyProgress(progress, next, size, "正在继续上传到 OneDrive")
}
file, err := os.Open(localPath)
if err != nil {
return graphItem{}, domain.Wrap(domain.CodeDriverError, err)
}
defer file.Close()
if _, err := file.Seek(resume.uploadedBytes, io.SeekStart); err != nil {
return graphItem{}, domain.Wrap(domain.CodeDriverError, err)
}
buf := make([]byte, uploadChunkSize)
uploaded := resume.uploadedBytes
totalParts := int((size + uploadChunkSize - 1) / uploadChunkSize)
if totalParts < 1 {
totalParts = 1
}
var completed graphItem
for part := int(uploaded/uploadChunkSize) + 1; uploaded < size; part++ {
if err := ctx.Err(); err != nil {
return graphItem{}, err
}
want := min(uploadChunkSize, size-uploaded)
count, readErr := io.ReadFull(file, buf[:want])
if readErr != nil && readErr != io.ErrUnexpectedEOF {
return graphItem{}, domain.Wrap(domain.CodeDriverError, readErr)
}
if int64(count) != want {
return graphItem{}, domain.Errorf(domain.CodeDriverError, "读取本地上传分片长度异常")
}
start := uploaded
end := start + int64(count) - 1
uploadutil.NotifyProgress(progress, uploaded, size, fmt.Sprintf("正在上传到 OneDrive,分片(%d/%d)", part, totalParts))
item, err := d.putUploadChunk(ctx, resume.uploadURL, buf[:count], start, end, size)
if err != nil {
return graphItem{}, err
}
if item.ID != "" {
completed = item
}
uploaded += int64(count)
resume.uploadedBytes = uploaded
if item.ID != "" {
resume.fileID = item.ID
resume.finalName = strings.TrimSpace(item.Name)
}
persistOneDriveResumeState(onState, resume)
uploadutil.NotifyProgress(progress, uploaded, size, fmt.Sprintf("正在上传到 OneDrive,分片(%d/%d)", part, totalParts))
}
if completed.ID == "" {
return graphItem{}, domain.Errorf(domain.CodeDriverError, "OneDrive 上传完成但未返回文件信息")
}
return completed, nil
}
func (d *Driver) getUploadSession(ctx context.Context, uploadURL string) (uploadSession, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, uploadURL, nil)
if err != nil {
return uploadSession{}, domain.Wrap(domain.CodeInternal, err)
}
resp, body, err := httpx.Execute(d.client, req, httpx.DefaultReadLimit)
if err != nil {
return uploadSession{}, domain.Wrap(domain.CodeDriverError, err)
}
if resp.StatusCode != http.StatusOK {
return uploadSession{}, domain.Errorf(domain.CodeDriverError, "OneDrive 上传会话已失效或无法恢复,HTTP %d:%s", resp.StatusCode, httpx.Truncate(body, 300))
}
var session uploadSession
if err := json.Unmarshal(body, &session); err != nil {
return uploadSession{}, domain.Errorf(domain.CodeDriverError, "OneDrive 上传会话状态格式异常:%s", httpx.Truncate(body, 300))
}
return session, nil
}
func oneDriveNextOffset(ranges []string, size int64) (int64, error) {
if len(ranges) == 0 {
return 0, domain.Errorf(domain.CodeDriverError, "OneDrive 上传会话未返回待上传范围")
}
start, _, _ := strings.Cut(strings.TrimSpace(ranges[0]), "-")
offset, err := strconv.ParseInt(strings.TrimSpace(start), 10, 64)
if err != nil || offset < 0 || offset > size {
return 0, domain.Errorf(domain.CodeDriverError, "OneDrive 上传会话返回无效范围: %s", ranges[0])
}
return offset, nil
}
func (d *Driver) putUploadChunk(ctx context.Context, uploadURL string, data []byte, start, end, total int64) (graphItem, error) {
for attempt := 0; attempt < uploadAttempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(data))
if err != nil {
return graphItem{}, domain.Wrap(domain.CodeInternal, err)
}
req.ContentLength = int64(len(data))
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(data)))
req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
req.Header.Set("Content-Type", "application/octet-stream")
resp, body, err := httpx.Execute(d.client, req, httpx.DefaultReadLimit)
if err != nil {
if attempt+1 == uploadAttempts {
return graphItem{}, domain.Wrap(domain.CodeDriverError, err)
}
if err := waitRetry(ctx, time.Duration(attempt+1)*time.Second); err != nil {
return graphItem{}, err
}
continue
}
if resp.StatusCode == http.StatusAccepted {
return graphItem{}, nil
}
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
var item graphItem
if err := json.Unmarshal(body, &item); err != nil {
return graphItem{}, domain.Errorf(domain.CodeDriverError, "OneDrive 上传完成响应格式异常:%s", httpx.Truncate(body, 300))
}
return item, nil
}
if (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError) && attempt+1 < uploadAttempts {
if err := waitRetry(ctx, retryDelay(resp.Header, attempt)); err != nil {
return graphItem{}, err
}
continue
}
return graphItem{}, domain.Errorf(domain.CodeDriverError, "OneDrive 上传分片 HTTP %d:%s", resp.StatusCode, httpx.Truncate(body, 300))
}
return graphItem{}, domain.Errorf(domain.CodeDriverError, "OneDrive 上传分片失败")
}
func (d *Driver) uploadItemURL(parentID, fileName, suffix string) string {
parentID = d.normalizeParent(parentID)
escapedName := filepath.ToSlash(fileName)
if strings.HasPrefix(parentID, "/") {
path := strings.Trim(parentID, "/")
if path != "" {
path += "/"
}
return graphPathURL("/"+path+escapedName, suffix)
}
return graphItemURL(parentID, ":/"+url.PathEscape(fileName)+":"+suffix)
}
func waitRetry(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}