天翼支持家庭云
This commit is contained in:
1 parent
85922d5c2e
commit
8bcfa7df2f
169 files changed
+568
-102
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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
@@ -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.
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.
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
Reference in new issue
Block a user