天翼支持家庭云

This commit is contained in:
King committed 2026-07-29 18:40:45 +08:00
1 parent 85922d5c2e
commit 8bcfa7df2f
169 files changed
+568 -102

No files matched your search

+8 -2
View File
@@ -110,12 +110,18 @@ func (d *Driver) refreshSession(ctx context.Context, accessToken string) error {
}
return domain.Errorf(domain.CodeAuthExpired, "%s", msg)
}
if out.SessionKey == "" || out.SessionSecret == "" {
return domain.Errorf(domain.CodeAuthExpired, "刷新会话成功但缺少 sessionKey/sessionSecret")
if d.isFamily() {
if out.FamilySessionKey == "" || out.FamilySessionSecret == "" {
return domain.Errorf(domain.CodeAuthExpired, "刷新会话成功但缺少家庭云会话信息")
}
} else if out.SessionKey == "" || out.SessionSecret == "" {
return domain.Errorf(domain.CodeAuthExpired, "刷新会话成功但缺少个人云会话信息")
}
d.mu.Lock()
d.sessionKey = out.SessionKey
d.sessionSecret = out.SessionSecret
d.familyKey = out.FamilySessionKey
d.familySecret = out.FamilySessionSecret
d.loginName = out.LoginName
if out.RefreshToken != "" {
d.refreshToken = out.RefreshToken
+4 -2
View File
@@ -5,9 +5,11 @@ import "litepan/pkg/jsonvalue"
type flexString = jsonvalue.FlexibleString
type Addition struct {
RefreshToken string `json:"refresh_token" label:"Token" type:"password" form:"required,full"`
AccessToken string `json:"access_token" label:"访问令牌 access_token" type:"password" form:"pair=auth"`
RefreshToken string `json:"refresh_token" label:"刷新令牌 refresh_token" type:"password" form:"required,pair=auth"`
SpaceType string `json:"space_type" label:"云空间" type:"select" options:"personal:个人云,family:家庭云" default:"personal" form:"pair=space"`
DeleteMode string `json:"delete_mode" label:"删除模式" type:"select" options:"trash:移动到回收站,delete:永久删除" default:"trash" form:"pair=opts1"`
DownloadMode string `json:"download_mode" label:"下载模式" type:"select" options:"redirect:302重定向,proxy:本机代理" default:"redirect" form:"pair=opts1"`
RootFolderID string `json:"root_folder_id" label:"根目录ID(默认 -11)" default:"-11" form:"pair=opts2"`
RootFolderID string `json:"root_folder_id" label:"根目录ID(默认空间根目录)" default:"-11" form:"pair=space" default_by:"space_type" defaults:"personal=-11,family=/"`
CacheTTL flexString `json:"cache_ttl" label:"缓存时间(分钟)" type:"number" default:"30" form:"pair=opts2"`
}
+61 -16
View File
@@ -25,14 +25,19 @@ type Driver struct {
refreshToken string
sessionKey string
sessionSecret string
familyKey string
familySecret string
familyID string
loginName string
itemCache map[string]domain.FileItem
itemOrder []string
}
var config = driver.Config{
Name: "189_cloud",
DisplayName: "天翼云盘",
Description: "天翼云盘 PC 接口接入,支持扫码登录和文件管理",
CardTags: []string{"个人云", "扫码登录", "支持302", "支持秒传"},
Description: "天翼云盘 PC 接口接入,支持个人云、家庭云和文件管理",
CardTags: []string{"个人云", "家庭云", "扫码登录", "支持302", "支持秒传"},
SortOrder: 7,
AuthLabel: "扫码登录",
CardColor: "#FEC52C",
@@ -77,16 +82,36 @@ func (d *Driver) Init(ctx context.Context) error {
})
}
d.mu.Lock()
if d.accessToken == "" {
d.accessToken = strings.TrimSpace(d.add.AccessToken)
}
if d.refreshToken == "" {
d.refreshToken = strings.TrimSpace(d.add.RefreshToken)
}
access := d.accessToken
empty := d.refreshToken == ""
d.mu.Unlock()
if empty {
return domain.Errorf(domain.CodeValidation, "请先扫码登录获取天翼云盘授权")
}
if !d.hasSession() {
if _, err := d.doRefresh(ctx); err != nil {
if access != "" {
if err := d.refreshSession(ctx, access); err != nil {
if !isSessionExpired(err) {
return err
}
if _, err := d.doRefresh(ctx); err != nil {
return err
}
}
} else {
if _, err := d.doRefresh(ctx); err != nil {
return err
}
}
}
if d.isFamily() {
if err := d.ensureFamilyID(ctx); err != nil {
return err
}
}
@@ -123,21 +148,34 @@ func (d *Driver) ExplainConnectionError(technical string, saving bool) string {
}
func (d *Driver) ListFiles(ctx context.Context, parentID string) ([]domain.FileItem, error) {
parent := d.normalizeParent(parentID)
parent := d.apiParentID(parentID)
endpoint := apiURL + "/listFiles.action"
baseParams := map[string]string{
"folderId": parent,
"fileType": "0",
"mediaAttr": "0",
"iconOption": "5",
}
if d.isFamily() {
endpoint = apiURL + "/family/file/listFiles.action"
baseParams["familyId"] = d.currentFamilyID()
baseParams["orderBy"] = "1"
baseParams["descending"] = "false"
} else {
baseParams["recursive"] = "0"
baseParams["orderBy"] = "filename"
baseParams["descending"] = "false"
}
var out []domain.FileItem
for page := 1; page < 10000; page++ {
var resp listResp
err := d.apiRequest(ctx, http.MethodGet, apiURL+"/listFiles.action", map[string]string{
"folderId": parent,
"fileType": "0",
"mediaAttr": "0",
"iconOption": "5",
"pageNum": itoa(page),
"pageSize": itoa(listPageSize),
"recursive": "0",
"orderBy": "filename",
"descending": "false",
}, &resp)
params := make(map[string]string, len(baseParams)+2)
for key, value := range baseParams {
params[key] = value
}
params["pageNum"] = itoa(page)
params["pageSize"] = itoa(listPageSize)
err := d.apiRequest(ctx, http.MethodGet, endpoint, params, &resp)
if err != nil {
return nil, err
}
@@ -158,12 +196,13 @@ func (d *Driver) ListFiles(ctx context.Context, parentID string) ([]domain.FileI
break
}
}
d.rememberItems(out)
return out, nil
}
func (d *Driver) GetFileInfo(ctx context.Context, fileID string) (*domain.FileItem, error) {
id := strings.TrimSpace(fileID)
if id == "" || id == "/" || id == d.rootID() {
if id == "" || id == "/" || id == d.rootID() || (d.isFamily() && id == "-11") {
return &domain.FileItem{
ID: d.rootID(),
Name: "根目录",
@@ -171,6 +210,12 @@ func (d *Driver) GetFileInfo(ctx context.Context, fileID string) (*domain.FileIt
IDKind: domain.IDStable,
}, nil
}
if d.isFamily() {
if item, ok := d.cachedItem(id); ok {
return &item, nil
}
return d.findFamilyItem(ctx, id)
}
raw, err := d.fetchFileInfo(ctx, id)
if err != nil {
+135
View File
@@ -0,0 +1,135 @@
package cloud189
import (
"context"
"net/http"
"strings"
"litepan/internal/domain"
)
const familyItemCacheLimit = 4096
func (d *Driver) isFamily() bool {
return strings.EqualFold(strings.TrimSpace(d.add.SpaceType), "family")
}
func (d *Driver) currentFamilyID() string {
d.mu.Lock()
defer d.mu.Unlock()
return d.familyID
}
func (d *Driver) uploadSpaceKey() string {
if d.isFamily() {
return "family:" + d.currentFamilyID()
}
return "personal"
}
func (d *Driver) ensureFamilyID(ctx context.Context) error {
if d.currentFamilyID() != "" {
return nil
}
var resp familyListResp
if err := d.apiRequest(ctx, http.MethodGet, apiURL+"/family/manage/getFamilyList.action", nil, &resp); err != nil {
return err
}
if len(resp.FamilyInfoResp) == 0 {
return domain.Errorf(domain.CodeValidation, "当前天翼账号没有可用的家庭云")
}
d.mu.Lock()
loginName := d.loginName
d.mu.Unlock()
selected := ""
for _, info := range resp.FamilyInfoResp {
remark := strings.TrimSpace(info.RemarkName)
if remark != "" && strings.Contains(loginName, remark) {
selected = info.FamilyID.String()
break
}
}
if selected == "" {
for _, info := range resp.FamilyInfoResp {
if info.UseFlag != 0 {
selected = info.FamilyID.String()
break
}
}
}
if selected == "" {
selected = resp.FamilyInfoResp[0].FamilyID.String()
}
if selected == "" {
return domain.Errorf(domain.CodeDriverError, "天翼云盘未返回家庭云ID")
}
d.mu.Lock()
d.familyID = selected
d.mu.Unlock()
return nil
}
func (d *Driver) rememberItems(items []domain.FileItem) {
d.mu.Lock()
defer d.mu.Unlock()
if d.itemCache == nil {
d.itemCache = make(map[string]domain.FileItem)
}
for _, item := range items {
if item.ID == "" {
continue
}
if _, exists := d.itemCache[item.ID]; !exists {
d.itemOrder = append(d.itemOrder, item.ID)
}
d.itemCache[item.ID] = item
}
for len(d.itemOrder) > familyItemCacheLimit {
id := d.itemOrder[0]
d.itemOrder = d.itemOrder[1:]
delete(d.itemCache, id)
}
}
func (d *Driver) cachedItem(id string) (domain.FileItem, bool) {
d.mu.Lock()
defer d.mu.Unlock()
item, ok := d.itemCache[id]
return item, ok
}
func (d *Driver) forgetItems(ids []string) {
d.mu.Lock()
defer d.mu.Unlock()
for _, id := range ids {
delete(d.itemCache, id)
}
}
func (d *Driver) findFamilyItem(ctx context.Context, fileID string) (*domain.FileItem, error) {
queue := []string{d.rootID()}
visited := map[string]struct{}{}
for len(queue) > 0 {
parent := queue[0]
queue = queue[1:]
if _, ok := visited[parent]; ok {
continue
}
visited[parent] = struct{}{}
items, err := d.ListFiles(ctx, parent)
if err != nil {
return nil, err
}
for _, item := range items {
if item.ID == fileID {
found := item
return &found, nil
}
if item.IsDir {
queue = append(queue, item.ID)
}
}
}
return nil, domain.Errf(domain.CodeNotFound)
}
+19 -6
View File
@@ -20,12 +20,25 @@ type oauthRefreshResp struct {
}
type sessionResp struct {
ResCode json.RawMessage `json:"res_code"`
ResMessage string `json:"res_message"`
SessionKey string `json:"sessionKey"`
SessionSecret string `json:"sessionSecret"`
LoginName string `json:"loginName"`
RefreshToken string `json:"refreshToken"`
ResCode json.RawMessage `json:"res_code"`
ResMessage string `json:"res_message"`
AccessToken string `json:"accessToken"`
SessionKey string `json:"sessionKey"`
SessionSecret string `json:"sessionSecret"`
FamilySessionKey string `json:"familySessionKey"`
FamilySessionSecret string `json:"familySessionSecret"`
LoginName string `json:"loginName"`
RefreshToken string `json:"refreshToken"`
}
type familyListResp struct {
FamilyInfoResp []familyInfo `json:"familyInfoResp"`
}
type familyInfo struct {
FamilyID flexString `json:"familyId"`
RemarkName string `json:"remarkName"`
UseFlag int `json:"useFlag"`
}
type listResp struct {
+64 -21
View File
@@ -25,12 +25,21 @@ func (d *Driver) ResolveDownload(ctx context.Context, req driver.DownloadRequest
if fileID == "" {
return nil, domain.Errorf(domain.CodeValidation, "file_id 不能为空")
}
var out map[string]any
if err := d.apiRequest(ctx, http.MethodGet, apiURL+"/getFileDownloadUrl.action", map[string]string{
endpoint := apiURL + "/getFileDownloadUrl.action"
params := map[string]string{
"fileId": fileID,
"dt": "3",
"flag": "1",
}, &out); err != nil {
}
if d.isFamily() {
endpoint = apiURL + "/family/file/getFileDownloadUrl.action"
params = map[string]string{
"fileId": fileID,
"familyId": d.currentFamilyID(),
}
}
var out map[string]any
if err := d.apiRequest(ctx, http.MethodGet, endpoint, params, &out); err != nil {
return nil, err
}
downloadURL := firstString(anyString(out["fileDownloadUrl"]), anyString(out["downloadUrl"]), anyString(out["url"]))
@@ -41,9 +50,13 @@ func (d *Driver) ResolveDownload(ctx context.Context, req driver.DownloadRequest
downloadURL = regexp.MustCompile(`(?i)^http://`).ReplaceAllString(downloadURL, "https://")
fileName := ""
size := int64(0)
if raw, err := d.fetchFileInfo(ctx, fileID); err == nil && raw != nil {
fileName = strings.TrimSpace(raw.Name)
size = raw.size()
if d.isFamily() {
if item, err := d.GetFileInfo(ctx, fileID); err == nil && item != nil {
fileName = item.Name
size = item.Size
}
} else if raw, err := d.fetchFileInfo(ctx, fileID); err == nil && raw != nil {
fileName, size = strings.TrimSpace(raw.Name), raw.size()
}
headers := http.Header{}
headers.Set("User-Agent", resolveUA(req.UA))
@@ -82,12 +95,22 @@ func (d *Driver) CreateFolder(ctx context.Context, parentID, name string) (*doma
if err := uploadutil.ValidateFileName(folderName); err != nil {
return nil, err
}
var entry fileEntry
if err := d.apiRequest(ctx, http.MethodPost, apiURL+"/createFolder.action", map[string]string{
"parentFolderId": d.normalizeParent(parentID),
endpoint := apiURL + "/createFolder.action"
params := map[string]string{
"parentFolderId": d.apiParentID(parentID),
"folderName": folderName,
"relativePath": "",
}, &entry); err != nil {
}
if d.isFamily() {
endpoint = apiURL + "/family/file/createFolder.action"
params = map[string]string{
"familyId": d.currentFamilyID(),
"parentId": d.apiParentID(parentID),
"folderName": folderName,
}
}
var entry fileEntry
if err := d.apiRequest(ctx, http.MethodPost, endpoint, params, &entry); err != nil {
return nil, err
}
entry.isDir = true
@@ -98,6 +121,7 @@ func (d *Driver) CreateFolder(ctx context.Context, parentID, name string) (*doma
if item.Name == "" {
item.Name = folderName
}
d.rememberItems([]domain.FileItem{item})
return &item, nil
}
@@ -120,16 +144,30 @@ func (d *Driver) RenameFile(ctx context.Context, fileID, newName string) error {
if err != nil {
return err
}
if item.IsDir {
return d.apiRequest(ctx, http.MethodPost, apiURL+"/renameFolder.action", map[string]string{
"folderId": id,
"destFolderName": name,
}, nil)
method := http.MethodPost
prefix := apiURL
base := map[string]string{}
if d.isFamily() {
method = http.MethodGet
prefix = apiURL + "/family/file"
base["familyId"] = d.currentFamilyID()
}
return d.apiRequest(ctx, http.MethodPost, apiURL+"/renameFile.action", map[string]string{
"fileId": id,
"destFileName": name,
}, nil)
if item.IsDir {
base["folderId"] = id
base["destFolderName"] = name
if err := d.apiRequest(ctx, method, prefix+"/renameFolder.action", base, nil); err != nil {
return err
}
} else {
base["fileId"] = id
base["destFileName"] = name
if err := d.apiRequest(ctx, method, prefix+"/renameFile.action", base, nil); err != nil {
return err
}
}
item.Name = name
d.rememberItems([]domain.FileItem{*item})
return nil
}
func (d *Driver) DeleteFiles(ctx context.Context, fileIDs []string) error {
@@ -159,8 +197,10 @@ func (d *Driver) DeleteFiles(ctx context.Context, fileIDs []string) error {
if err := d.waitBatchTask(ctx, "CLEAR_RECYCLE", clearID, time.Second, 40*time.Second); err != nil {
return err
}
d.forgetItems(ids)
return nil
}
d.forgetItems(ids)
return nil
}
@@ -184,7 +224,7 @@ func (d *Driver) transferFiles(ctx context.Context, taskType string, fileIDs []s
if err != nil {
return err
}
taskID, err := d.createBatchTask(ctx, taskType, taskInfos, d.normalizeParent(targetParentID), nil)
taskID, err := d.createBatchTask(ctx, taskType, taskInfos, d.apiParentID(targetParentID), nil)
if err != nil {
return err
}
@@ -250,6 +290,9 @@ func (d *Driver) createBatchTask(ctx context.Context, taskType string, taskInfos
form.Set("type", taskType)
form.Set("taskInfos", string(raw))
form.Set("targetFolderId", targetFolderID)
if d.isFamily() {
form.Set("familyId", d.currentFamilyID())
}
for k, v := range extra {
form.Set(k, v)
}
@@ -271,7 +314,7 @@ func (d *Driver) waitBatchTask(ctx context.Context, taskType, taskID string, int
form.Set("type", taskType)
form.Set("taskId", taskID)
var resp map[string]any
if err := d.formRequest(ctx, http.MethodPost, apiURL+"/batch/checkBatchTask.action", form, &resp); err != nil {
if err := d.formRequestFor(ctx, http.MethodPost, apiURL+"/batch/checkBatchTask.action", form, &resp, false); err != nil {
return err
}
status := anyInt(resp["taskStatus"])
+1
View File
@@ -228,6 +228,7 @@ func finalizeQRLogin(ctx context.Context, client *http.Client, redirectURL strin
return &driver.QRPollResult{
Status: driver.QRSuccess,
Credentials: domain.AuthCredentials{
AccessToken: out.AccessToken,
RefreshToken: out.RefreshToken,
TokenExpires: time.Now().Add(7 * 24 * time.Hour),
},
+77 -12
View File
@@ -47,29 +47,66 @@ const (
)
func (d *Driver) rootID() string {
if id := strings.TrimSpace(d.add.RootFolderID); id != "" && id != "/" && id != "0" {
id := strings.TrimSpace(d.add.RootFolderID)
if !d.isRootAlias(id) {
return id
}
if d.isFamily() {
return "/"
}
return "-11"
}
func (d *Driver) normalizeParent(parentID string) string {
p := strings.TrimSpace(parentID)
if p == "" || p == "/" || p == "0" || p == "root" {
if d.isRootAlias(p) {
return d.rootID()
}
return p
}
func (d *Driver) isRootAlias(id string) bool {
switch strings.ToLower(strings.TrimSpace(id)) {
case "", "/", "0", "root":
return true
case "-11", "home":
return d.isFamily()
default:
return false
}
}
func (d *Driver) apiParentID(parentID string) string {
parent := d.normalizeParent(parentID)
if d.isFamily() && d.isRootAlias(parent) {
return ""
}
return parent
}
func (d *Driver) hasSession() bool {
return d.hasSessionFor(d.isFamily())
}
func (d *Driver) hasSessionFor(family bool) bool {
d.mu.Lock()
defer d.mu.Unlock()
if family {
return d.familyKey != "" && d.familySecret != ""
}
return d.sessionKey != "" && d.sessionSecret != ""
}
func (d *Driver) currentSession() (string, string) {
return d.currentSessionFor(d.isFamily())
}
func (d *Driver) currentSessionFor(family bool) (string, string) {
d.mu.Lock()
defer d.mu.Unlock()
if family {
return d.familyKey, d.familySecret
}
return d.sessionKey, d.sessionSecret
}
@@ -115,16 +152,20 @@ func (d *Driver) apiRequest(ctx context.Context, method, rawURL string, params m
}
func (d *Driver) formRequest(ctx context.Context, method, rawURL string, form url.Values, out any) error {
if !d.hasSession() {
return d.formRequestFor(ctx, method, rawURL, form, out, d.isFamily())
}
func (d *Driver) formRequestFor(ctx context.Context, method, rawURL string, form url.Values, out any, family bool) error {
if !d.hasSessionFor(family) {
if _, err := d.doRefresh(ctx); err != nil {
return err
}
}
if err := d.signedForm(ctx, method, rawURL, form, out); isSessionExpired(err) {
if err := d.signedFormFor(ctx, method, rawURL, form, out, family); isSessionExpired(err) {
if _, rerr := d.doRefresh(ctx); rerr != nil {
return rerr
}
return d.signedForm(ctx, method, rawURL, form, out)
return d.signedFormFor(ctx, method, rawURL, form, out, family)
} else if err != nil {
return err
}
@@ -134,9 +175,7 @@ func (d *Driver) formRequest(ctx context.Context, method, rawURL string, form ur
func (d *Driver) signedJSON(ctx context.Context, method, rawURL string, params map[string]string, out any) error {
query := clientSuffix()
for k, v := range params {
if strings.TrimSpace(v) != "" {
query.Set(k, v)
}
query.Set(k, v)
}
headers, err := d.signatureHeaders(method, rawURL, "")
if err != nil {
@@ -146,8 +185,12 @@ func (d *Driver) signedJSON(ctx context.Context, method, rawURL string, params m
}
func (d *Driver) signedForm(ctx context.Context, method, rawURL string, form url.Values, out any) error {
return d.signedFormFor(ctx, method, rawURL, form, out, d.isFamily())
}
func (d *Driver) signedFormFor(ctx context.Context, method, rawURL string, form url.Values, out any, family bool) error {
query := clientSuffix()
headers, err := d.signatureHeaders(method, rawURL, "")
headers, err := d.signatureHeadersFor(method, rawURL, "", family)
if err != nil {
return err
}
@@ -168,6 +211,9 @@ func (d *Driver) rawJSON(ctx context.Context, method, rawURL string, query url.V
if err != nil {
return domain.Wrap(domain.CodeDriverError, err)
}
if is189AuthExpiredPayload(data) {
return domain.Errorf(domain.CodeAuthExpired, "天翼云盘认证会话已失效")
}
if resp.StatusCode != http.StatusOK {
return domain.Errorf(domain.CodeDriverError, "天翼云盘 API HTTP %d: %s", resp.StatusCode, httpx.Truncate(data, 300))
}
@@ -193,6 +239,9 @@ func (d *Driver) rawForm(ctx context.Context, method, rawURL string, query url.V
if err != nil {
return domain.Wrap(domain.CodeDriverError, err)
}
if is189AuthExpiredPayload(data) {
return domain.Errorf(domain.CodeAuthExpired, "天翼云盘认证会话已失效")
}
if resp.StatusCode != http.StatusOK {
return domain.Errorf(domain.CodeDriverError, "天翼云盘 API HTTP %d: %s", resp.StatusCode, httpx.Truncate(data, 300))
}
@@ -200,7 +249,7 @@ func (d *Driver) rawForm(ctx context.Context, method, rawURL string, query url.V
}
func parse189Response(data []byte, out any) error {
if bytes.Contains(data, []byte("InvalidSessionKey")) || bytes.Contains(data, []byte("userSessionBO is null")) {
if is189AuthExpiredPayload(data) {
return domain.Errorf(domain.CodeAuthExpired, "天翼云盘会话已失效")
}
trimmed := bytes.TrimSpace(data)
@@ -255,6 +304,14 @@ func parse189XMLResponse(data []byte, out any) error {
return nil
}
func is189AuthExpiredPayload(data []byte) bool {
lower := bytes.ToLower(data)
return bytes.Contains(lower, []byte("invalidsessionkey")) ||
bytes.Contains(lower, []byte("usersessionbo is null")) ||
bytes.Contains(lower, []byte("userinvalidopentoken")) ||
bytes.Contains(lower, []byte("unifyaccountinfo is null"))
}
func responseMessage(env map[string]json.RawMessage, keys ...string) string {
for _, key := range keys {
var s string
@@ -288,11 +345,19 @@ func isSessionExpired(err error) bool {
return true
}
msg := err.Error()
return strings.Contains(msg, "InvalidSessionKey") || strings.Contains(msg, "userSessionBO is null")
lower := strings.ToLower(msg)
return strings.Contains(lower, "invalidsessionkey") ||
strings.Contains(lower, "usersessionbo is null") ||
strings.Contains(lower, "userinvalidopentoken") ||
strings.Contains(lower, "unifyaccountinfo is null")
}
func (d *Driver) signatureHeaders(method, rawURL, params string) (map[string]string, error) {
sessionKey, sessionSecret := d.currentSession()
return d.signatureHeadersFor(method, rawURL, params, d.isFamily())
}
func (d *Driver) signatureHeadersFor(method, rawURL, params string, family bool) (map[string]string, error) {
sessionKey, sessionSecret := d.currentSessionFor(family)
if sessionKey == "" || sessionSecret == "" {
return nil, domain.Errorf(domain.CodeAuthExpired, "天翼云盘会话未初始化")
}
+149 -28
View File
@@ -7,8 +7,10 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
@@ -16,6 +18,7 @@ import (
"sort"
"strconv"
"strings"
"time"
"litepan/internal/domain"
"litepan/internal/driver"
@@ -30,6 +33,7 @@ type uploadHashInfo struct {
}
type cloud189ResumeCtx struct {
space string
parentID string
requestedName string
targetName string
@@ -58,8 +62,9 @@ func (d *Driver) UploadLocalFile(ctx context.Context, req driver.LocalUploadRequ
fileSize := local.Size
partSize := uploadPartSize(fileSize)
policy := uploadutil.NormalizeConflictPolicy(req.ConflictPolicy)
space := d.uploadSpaceKey()
resume := normalize189ResumeState(req.ResumeState, parentID, requestedName, fileSize, partSize, uploadHashInfo{})
resume := normalize189ResumeState(req.ResumeState, space, parentID, requestedName, fileSize, partSize, uploadHashInfo{})
hadResumeCandidate := resume != nil
targetName := requestedName
if resume != nil {
@@ -85,7 +90,7 @@ func (d *Driver) UploadLocalFile(ctx context.Context, req driver.LocalUploadRequ
if err != nil {
return nil, err
}
resume = normalize189ResumeState(req.ResumeState, parentID, requestedName, fileSize, partSize, hashInfo)
resume = normalize189ResumeState(req.ResumeState, space, parentID, requestedName, fileSize, partSize, hashInfo)
if hadResumeCandidate && resume == nil {
var skipped bool
targetName, skipped, err = d.prepareUploadName(ctx, parentID, requestedName, policy)
@@ -110,11 +115,14 @@ func (d *Driver) UploadLocalFile(ctx context.Context, req driver.LocalUploadRequ
} else {
uploadutil.NotifyProgress(req.OnProgress, 0, fileSize, "正在创建上传会话")
initParams := map[string]string{
"parentFolderId": parentID,
"parentFolderId": d.apiParentID(parentID),
"fileName": url.QueryEscape(targetName),
"fileSize": fmt.Sprintf("%d", fileSize),
"sliceSize": fmt.Sprintf("%d", partSize),
}
if d.isFamily() {
initParams["familyId"] = d.currentFamilyID()
}
if singlePart || emptyFile {
initParams["fileMd5"] = hashInfo.fileMD5
initParams["sliceMd5"] = hashInfo.sliceMD5
@@ -132,6 +140,7 @@ func (d *Driver) UploadLocalFile(ctx context.Context, req driver.LocalUploadRequ
}
rapid = anyInt(initData["fileDataExists"]) == 1 || anyInt(initResp["fileDataExists"]) == 1
resume = &cloud189ResumeCtx{
space: space,
parentID: parentID,
requestedName: requestedName,
targetName: targetName,
@@ -153,10 +162,7 @@ func (d *Driver) UploadLocalFile(ctx context.Context, req driver.LocalUploadRequ
if _, completed := resume.completedParts[partNumber]; completed {
continue
}
urlsResp, err := d.uploadEncryptedRequest(ctx, "getMultiUploadUrls", map[string]string{
"uploadFileId": uploadFileID,
"partInfo": partInfo,
})
urlsResp, err := d.getMultiUploadURLs(ctx, uploadFileID, partInfo, partNumber, totalParts)
if err != nil {
return nil, err
}
@@ -214,8 +220,70 @@ func (d *Driver) UploadLocalFile(ctx context.Context, req driver.LocalUploadRequ
}, nil
}
func normalize189ResumeState(state map[string]any, parentID, requestedName string, fileSize, partSize int64, hashes uploadHashInfo) *cloud189ResumeCtx {
if len(state) == 0 || strings.TrimSpace(uploadutil.AnyString(state["parent_id"])) != parentID ||
func (d *Driver) getMultiUploadURLs(ctx context.Context, uploadFileID, partInfo string, partNumber, totalParts int) (map[string]any, error) {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
delay := time.Duration(attempt) * 300 * time.Millisecond
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
resp, err := d.uploadEncryptedRequest(ctx, "getMultiUploadUrls", map[string]string{
"uploadFileId": uploadFileID,
"partInfo": partInfo,
})
if err == nil {
return resp, nil
}
lastErr = err
if !retryableUploadURLFailure(ctx, err) {
return nil, err
}
}
return nil, domain.Errorf(
domain.CodeDriverError,
"获取第 %d/%d 个分片上传地址失败(已自动重试 2 次):%s",
partNumber,
totalParts,
rootErrorMessage(lastErr),
)
}
func retryableUploadURLFailure(ctx context.Context, err error) bool {
if err == nil || ctx.Err() != nil || isSessionExpired(err) {
return false
}
var urlErr *url.Error
var netErr net.Error
return errors.As(err, &urlErr) ||
errors.As(err, &netErr) ||
errors.Is(err, io.EOF) ||
errors.Is(err, io.ErrUnexpectedEOF)
}
func rootErrorMessage(err error) string {
for err != nil {
next := errors.Unwrap(err)
if next == nil {
return err.Error()
}
err = next
}
return "未知网络错误"
}
func normalize189ResumeState(state map[string]any, space, parentID, requestedName string, fileSize, partSize int64, hashes uploadHashInfo) *cloud189ResumeCtx {
stateSpace := strings.TrimSpace(uploadutil.AnyString(state["space"]))
if stateSpace == "" {
stateSpace = "personal"
}
if len(state) == 0 || stateSpace != space ||
strings.TrimSpace(uploadutil.AnyString(state["parent_id"])) != parentID ||
strings.TrimSpace(uploadutil.AnyString(state["requested_name"])) != requestedName {
return nil
}
@@ -236,6 +304,7 @@ func normalize189ResumeState(state map[string]any, parentID, requestedName strin
totalParts = int((fileSize + partSize - 1) / partSize)
}
return &cloud189ResumeCtx{
space: space,
parentID: parentID,
requestedName: requestedName,
targetName: targetName,
@@ -259,6 +328,7 @@ func persist189ResumeState(onState driver.UploadStateCallback, resume *cloud189R
progress = 99
}
onState(map[string]any{
"space": resume.space,
"parent_id": resume.parentID,
"requested_name": resume.requestedName,
"target_name": resume.targetName,
@@ -410,7 +480,11 @@ func (d *Driver) uploadEncryptedRequestOnce(ctx context.Context, endpoint string
if err != nil {
return nil, err
}
rawURL := uploadURL + "/person/" + strings.TrimLeft(endpoint, "/")
space := "person"
if d.isFamily() {
space = "family"
}
rawURL := uploadURL + "/" + space + "/" + strings.TrimLeft(endpoint, "/")
query := clientSuffix()
query.Set("params", encrypted)
headers, err := d.signatureHeaders(http.MethodGet, rawURL, encrypted)
@@ -582,6 +656,13 @@ func (d *Driver) ResolveTransferHash(ctx context.Context, item *domain.FileItem,
if !allowStream || item == nil || strings.TrimSpace(item.ID) == "" {
return "", nil
}
if d.isFamily() {
info, err := d.GetFileInfo(ctx, item.ID)
if err != nil {
return "", err
}
return driver.HashFromItem(info, "md5"), nil
}
info, err := d.fetchFileInfo(ctx, item.ID)
if err != nil {
return "", err
@@ -608,13 +689,19 @@ func (d *Driver) RapidUploadByHash(ctx context.Context, req driver.RapidUploadRe
operation = "3"
}
var committed rapidCommitResp
if err := d.formRequest(ctx, http.MethodPost, commitURL, url.Values{
"opertype": {operation},
"resumePolicy": {"1"},
"uploadFileId": {uploadFileID},
"isLog": {"0"},
}, &committed); err != nil {
return nil, err
if d.isFamily() {
if err := d.commitFamilyRapidUpload(ctx, commitURL, uploadFileID, &committed); err != nil {
return nil, err
}
} else {
if err := d.formRequest(ctx, http.MethodPost, commitURL, url.Values{
"opertype": {operation},
"resumePolicy": {"1"},
"uploadFileId": {uploadFileID},
"isLog": {"0"},
}, &committed); err != nil {
return nil, err
}
}
return &driver.RapidUploadResult{
Reuse: true,
@@ -659,21 +746,55 @@ func (d *Driver) createRapidUpload(ctx context.Context, req driver.RapidUploadRe
parentID := d.normalizeParent(req.ParentID)
var created rapidCreateResp
if err := d.formRequest(ctx, http.MethodPost, apiURL+"/createUploadFile.action", url.Values{
"parentFolderId": {parentID},
"fileName": {fileName},
"size": {strconv.FormatInt(req.Size, 10)},
"md5": {hash},
"opertype": {"3"},
"flag": {"1"},
"resumePolicy": {"1"},
"isLog": {"0"},
}, &created); err != nil {
return rapidCreateResp{}, "", err
if d.isFamily() {
if err := d.apiRequest(ctx, http.MethodPost, apiURL+"/family/file/createFamilyFile.action", map[string]string{
"familyId": d.currentFamilyID(),
"parentId": d.apiParentID(parentID),
"fileMd5": hash,
"fileName": fileName,
"fileSize": strconv.FormatInt(req.Size, 10),
"resumePolicy": "1",
}, &created); err != nil {
return rapidCreateResp{}, "", err
}
} else {
if err := d.formRequest(ctx, http.MethodPost, apiURL+"/createUploadFile.action", url.Values{
"parentFolderId": {parentID},
"fileName": {fileName},
"size": {strconv.FormatInt(req.Size, 10)},
"md5": {hash},
"opertype": {"3"},
"flag": {"1"},
"resumePolicy": {"1"},
"isLog": {"0"},
}, &created); err != nil {
return rapidCreateResp{}, "", err
}
}
return created, parentID, nil
}
func (d *Driver) commitFamilyRapidUpload(ctx context.Context, commitURL, uploadFileID string, out any) error {
commit := func() error {
headers, err := d.signatureHeadersFor(http.MethodPost, commitURL, "", true)
if err != nil {
return err
}
headers["ResumePolicy"] = "1"
headers["UploadFileId"] = uploadFileID
headers["FamilyId"] = d.currentFamilyID()
return d.rawForm(ctx, http.MethodPost, commitURL, clientSuffix(), url.Values{}, headers, out)
}
if err := commit(); isSessionExpired(err) {
if _, refreshErr := d.doRefresh(ctx); refreshErr != nil {
return refreshErr
}
return commit()
} else {
return err
}
}
func normalize189RapidProbeError(err error) error {
message := err.Error()
switch {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more