Skip to content
41 changes: 41 additions & 0 deletions drivers/189pc/driver_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package _189pc

import (
"errors"
"fmt"
"path"
"slices"
"strings"
"testing"

"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
)

func TestFamilyTransferTempNamePreservesExtension(t *testing.T) {
Expand All @@ -31,6 +36,42 @@ func TestFamilyTransferTempNamePreservesExtension(t *testing.T) {
}
}

// TestCASSliceDigestPrefersFinalDigest 最终摘要优先:旧 .cas 只存了这个值,
// 它已经是逐片 MD5 的汇总结果,不能因为同时存在逐片列表就重新推算覆盖它。
func TestCASSliceDigestPrefersFinalDigest(t *testing.T) {
fileMD5 := strings.Repeat("1", 32)
parts := []string{strings.Repeat("a", 32), strings.Repeat("b", 32)}

if got, want := casSliceDigest(fileMD5, "", parts), torrent.SliceMD5FromPieces(parts, fileMD5); got != want {
t.Fatalf("casSliceDigest() without explicit digest = %q, want %q", got, want)
}
explicit := strings.Repeat("F", 32)
if got := casSliceDigest(fileMD5, strings.ToLower(explicit), parts); got != explicit {
t.Fatalf("casSliceDigest() = %q, want the explicit digest %q", got, explicit)
}
if got := casSliceDigest(fileMD5, "", nil); got != strings.ToUpper(fileMD5) {
t.Fatalf("casSliceDigest() without any slice info = %q, want file MD5", got)
}
}

// TestIsDayFlowOverLimited 天翼把当日流量用尽作为 HTTP 200 的业务错误码返回,
// 只能按错误码文本识别;识别不出来就只能在批量秒传里逐个文件重复失败。
func TestIsDayFlowOverLimited(t *testing.T) {
quota := &RespErr{Code: "UserDayFlowOverLimited", Msg: "sessionKey=abc,data flow is out"}
if !isDayFlowOverLimited(fmt.Errorf("initMultiUpload failed: %w", quota)) {
t.Fatal("UserDayFlowOverLimited was not recognized through the error chain")
}
if isDayFlowOverLimited(&RespErr{Code: "InvalidSessionKey"}) {
t.Fatal("an unrelated business error must not be treated as a quota error")
}
if isDayFlowOverLimited(nil) {
t.Fatal("nil error must not be treated as a quota error")
}
if !errors.Is(wrapDayFlowOverLimit(quota), errs.DailyFlowLimitExceeded) {
t.Fatal("quota errors must be detectable with errors.Is")
}
}

func TestUploadProgressKeysIncludeExtension(t *testing.T) {
jpgKeys := uploadProgressKeys("session", "same-md5", "photo.jpg")
txtKeys := uploadProgressKeys("session", "same-md5", "photo.txt")
Expand Down
14 changes: 5 additions & 9 deletions drivers/189pc/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ import (
"encoding/pem"
"encoding/xml"
"fmt"
"math"
"net/http"
"regexp"
"strings"
"time"

"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
)
Expand Down Expand Up @@ -214,15 +214,11 @@ func BoolToNumber(b bool) int {
// 对分片数量有限制
// 10MIB 20 MIB 999片
// 50MIB 60MIB 70MIB 80MIB ∞MIB 1999片
//
// 规则由文件大小唯一决定,与用户配置无关;实现放在 pkg/torrent.CASSliceSize,
// 保证上传侧、种子生成侧、秒传侧共用同一份定义。
func partSize(size int64) int64 {
const DEFAULT = 1024 * 1024 * 10 // 10MIB
if size > DEFAULT*2*999 {
return int64(math.Max(math.Ceil((float64(size)/1999) /*=单个切片大小*/ /float64(DEFAULT)) /*=倍率*/, 5) * DEFAULT)
}
if size > DEFAULT*999 {
return DEFAULT * 2 // 20MIB
}
return DEFAULT
return torrent.CASSliceSize(size)
}

func isBool(bs ...bool) bool {
Expand Down
18 changes: 18 additions & 0 deletions drivers/189pc/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,26 @@ import (
"encoding/xml"
"testing"
"time"

"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
)

// TestPartSizeMatchesCASSliceSize 上传分片与 CAS 秒传分片必须是同一个值,
// 否则同一份字节算出的 sliceMd5 与云端摘要不一致,秒传永远命不中。
func TestPartSizeMatchesCASSliceSize(t *testing.T) {
const tenMiB = 10 * 1024 * 1024
for _, size := range []int64{
0, 1, tenMiB, tenMiB + 1,
tenMiB*999 - 1, tenMiB * 999, tenMiB*999 + 1,
tenMiB * 2 * 999, tenMiB*2*999 + 1,
100 << 30,
} {
if got, want := partSize(size), torrent.CASSliceSize(size); got != want {
t.Fatalf("partSize(%d) = %d, CASSliceSize = %d", size, got, want)
}
}
}

func TestTimeUnmarshal(t *testing.T) {
tests := []struct {
name string
Expand Down
70 changes: 68 additions & 2 deletions drivers/189pc/seed_rapid.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package _189pc

import (
"context"
"errors"
"strings"

"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)

Expand All @@ -19,17 +22,80 @@ func (d *Cloud189PC) RapidHashNeedsPieces() bool {
return true
}

// RapidUploadByHashes 使用种子中的 MD5 哈希尝试秒传
// RapidUploadByHashes 使用种子中的哈希尝试秒传。
//
// 天翼云盘的秒传要经过 initMultiUpload → checkTransSecond → commitMultiUploadFile,
// 且除整文件 MD5 外还要用 sliceMd5 判重:大于单个分片的文件只带整文件 MD5
// 是匹配不上的。种子里既然带了分片 MD5(或旧 .cas 载荷里的 sliceMd5),就
// 直接走 CAS;只有整文件 MD5 时才退回旧上传接口。
func (d *Cloud189PC) RapidUploadByHashes(ctx context.Context, dstDir model.Obj, req *driver.SeedRapidUploadRequest, overwrite bool) (model.Obj, error) {
md5Hash := req.Whole.GetHash(utils.MD5)
if len(md5Hash) < utils.MD5.Width {
return nil, errs.ErrUnavailableHash
}

var casErr error
if req.SliceMD5 != "" || len(req.SliceMD5s) > 0 {
// 天翼的分片大小由文件大小唯一决定,种子里的 piece_size 不参与;
// 只要手里有最终摘要(或逐片列表),就可以直接拿去判重
obj, err := d.rapidUploadByCAS(ctx, dstDir, req.Name, req.Size, md5Hash, req.SliceMD5, req.SliceMD5s, torrent.CASSliceSize(req.Size), overwrite)
if err == nil {
return obj, nil
}
casErr = err
// 当日流量用尽是账号级状态:旧接口同样是「建上传会话」,
// 再试一次只会再报同样的错
if isDayFlowOverLimited(err) {
return nil, wrapDayFlowOverLimit(err)
}
}

stream := driver.NewSeedHashStream(req)
obj, err := d.RapidUpload(ctx, dstDir, stream, d.isFamily(), overwrite)
if err != nil {
return nil, err
// CAS 的报错更具体(云端不存在 / 分片不匹配),失败时优先上报它
if casErr != nil {
return nil, wrapRapidUploadError(casErr)
}
return nil, wrapRapidUploadError(err)
}
return obj, nil
}

// casSliceDigest 返回天翼判重时真正比较的最终分片摘要。
//
// 显式摘要优先:旧版 .cas 只有 sliceMd5 一个字段,它本身就是「逐片 MD5 汇总
// 后的最终值」,直接用它即可;只有缺摘要时才用逐片列表按规范推算。
func casSliceDigest(fileMD5, sliceMD5 string, sliceMD5s []string) string {
if digest := strings.ToUpper(strings.TrimSpace(sliceMD5)); digest != "" {
return digest
}
return torrent.SliceMD5FromPieces(sliceMD5s, fileMD5)
}

// wrapRapidUploadError 把账号级限流翻译成可判断、可读的错误
func wrapRapidUploadError(err error) error {
if isDayFlowOverLimited(err) {
return wrapDayFlowOverLimit(err)
}
return err
}

func wrapDayFlowOverLimit(err error) error {
return errs.NewErr(errs.DailyFlowLimitExceeded,
"the 189 account has used up its daily upload flow; retry after the daily reset or switch account/membership (%v)", err)
}

// isDayFlowOverLimited reports whether the drive refused the request because the
// account's daily upload/transfer flow is exhausted (UserDayFlowOverLimited).
// 天翼把这类业务错误放在 HTTP 200 的响应体里,只能按错误码文本判断。
func isDayFlowOverLimited(err error) bool {
var respErr *RespErr
if !errors.As(err, &respErr) {
return false
}
text := strings.ToLower(strings.Join([]string{
respErr.Code, respErr.Msg, respErr.Message, respErr.ErrorMsg, respErr.ResMessage,
}, " "))
return strings.Contains(text, "userdayflowoverlimited") || strings.Contains(text, "flow is out")
}
36 changes: 18 additions & 18 deletions drivers/189pc/torrent.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
// 解析 torrent
t, err := torrent.Decode(torrentData)
if err != nil {
return nil, fmt.Errorf("解析 torrent 失败: %w", err)
return nil, fmt.Errorf("failed to parse torrent: %w", err)
}

// 检查是否包含 CAS 扩展信息
if !t.HasCASInfo() {
return nil, fmt.Errorf("torrent 不包含 CAS 扩展信息,无法秒传")
return nil, fmt.Errorf("torrent has no CAS metadata, rapid upload is not possible")
}

cas := t.CAS
Expand All @@ -72,9 +72,9 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
}

// 复用统一的 CAS 秒传核心实现
respObj, err := y.rapidUploadByCAS(ctx, dstDir, fileName, fileSize, fileMD5, sliceMD5s, cas.SliceSize, overwrite)
respObj, err := y.rapidUploadByCAS(ctx, dstDir, fileName, fileSize, fileMD5, cas.SliceMD5, sliceMD5s, cas.SliceSize, overwrite)
if err != nil {
utils.Log.Errorf("[RapidUpload] 秒传失败: fileMD5=%s, err=%v", fileMD5, err)
utils.Log.Errorf("[RapidUpload] rapid upload failed: fileMD5=%s, err=%v", fileMD5, err)
return nil, err
}

Expand All @@ -85,7 +85,7 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
go func() {
torrentName := fileName + ".cas.torrent"
infoHash, _ := GetInfoHashHex(torrentData)
utils.Log.Infof("秒传成功,上传 torrent: %s (info_hash: %s, size: %d bytes)",
utils.Log.Infof("rapid upload succeeded, uploading torrent: %s (info_hash: %s, size: %d bytes)",
torrentName, infoHash, len(torrentData))

torrentFileStream := &stream.FileStream{
Expand All @@ -100,9 +100,9 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
}
_, uploadErr := y.fastUpload(context.Background(), capturedDstDir, torrentFileStream, func(p float64) {}, capturedIsFamily, false, false)
if uploadErr != nil {
utils.Log.Warnf("上传 torrent 文件失败: %v", uploadErr)
utils.Log.Warnf("failed to upload torrent file: %v", uploadErr)
} else {
utils.Log.Infof("torrent 文件已上传: %s", torrentName)
utils.Log.Infof("torrent file uploaded: %s", torrentName)
op.Cache.DeleteDirectory(y, capturedDstDir.GetPath())
}
}()
Expand Down Expand Up @@ -153,11 +153,11 @@ func ComputePieceSHA1(data []byte) []byte {
func ExtractCASFromTorrent(torrentData []byte) (*torrent.CASInfo, string, int64, error) {
t, err := torrent.Decode(torrentData)
if err != nil {
return nil, "", 0, fmt.Errorf("解析 torrent 失败: %w", err)
return nil, "", 0, fmt.Errorf("failed to parse torrent: %w", err)
}

if !t.HasCASInfo() {
return nil, "", 0, fmt.Errorf("torrent 不包含 CAS 扩展信息")
return nil, "", 0, fmt.Errorf("torrent has no CAS metadata")
}

return t.CAS, t.Info.Name, t.GetTotalSize(), nil
Expand All @@ -168,7 +168,7 @@ func ExtractCASFromTorrent(torrentData []byte) (*torrent.CASInfo, string, int64,
func InjectCASIntoTorrent(torrentData []byte, fileMD5 string, sliceMD5s []string, sliceSize int64) ([]byte, error) {
t, err := torrent.Decode(torrentData)
if err != nil {
return nil, fmt.Errorf("解析 torrent 失败: %w", err)
return nil, fmt.Errorf("failed to parse torrent: %w", err)
}

// 计算 sliceMD5(统一走规范实现)
Expand Down Expand Up @@ -253,7 +253,7 @@ func (y *Cloud189PC) torrentFollowCopy(srcFolderId string, srcFileName string, d
go func() {
torrentFile, err := y.findFileByName(context.Background(), torrentName, srcFolderId, isFamily)
if err != nil {
utils.Log.Debugf("查找 torrent 文件失败(跟随复制): %v", err)
utils.Log.Debugf("failed to look up torrent file (follow copy): %v", err)
return
}
// 复制 torrent 文件到目标目录
Expand All @@ -265,11 +265,11 @@ func (y *Cloud189PC) torrentFollowCopy(srcFolderId string, srcFileName string, d
IsFolder: 0,
})
if copyErr != nil {
utils.Log.Warnf("跟随复制 torrent 文件失败: %v", copyErr)
utils.Log.Warnf("failed to copy torrent file (follow): %v", copyErr)
return
}
if err = y.WaitBatchTask("COPY", resp.TaskID, time.Second); err != nil {
utils.Log.Warnf("等待跟随复制 torrent 文件失败: %v", err)
utils.Log.Warnf("failed to wait for torrent copy (follow): %v", err)
}
}()
}
Expand All @@ -291,7 +291,7 @@ func (y *Cloud189PC) torrentFollowMove(srcFolderId string, srcFileName string, d
go func() {
torrentFile, err := y.findFileByName(context.Background(), torrentName, srcFolderId, isFamily)
if err != nil {
utils.Log.Debugf("查找 torrent 文件失败(跟随移动): %v", err)
utils.Log.Debugf("failed to look up torrent file (follow move): %v", err)
return
}
// 移动 torrent 文件到目标目录
Expand All @@ -303,11 +303,11 @@ func (y *Cloud189PC) torrentFollowMove(srcFolderId string, srcFileName string, d
IsFolder: 0,
})
if moveErr != nil {
utils.Log.Warnf("跟随移动 torrent 文件失败: %v", moveErr)
utils.Log.Warnf("failed to move torrent file (follow): %v", moveErr)
return
}
if err = y.WaitBatchTask("MOVE", resp.TaskID, time.Millisecond*400); err != nil {
utils.Log.Warnf("等待跟随移动 torrent 文件失败: %v", err)
utils.Log.Warnf("failed to wait for torrent move (follow): %v", err)
}
}()
}
Expand All @@ -330,7 +330,7 @@ func (y *Cloud189PC) torrentFollowRename(folderId string, oldFileName string, ne
go func() {
torrentFile, err := y.findFileByName(context.Background(), oldTorrentName, folderId, isFamily)
if err != nil {
utils.Log.Debugf("查找 torrent 文件失败(跟随重命名): %v", err)
utils.Log.Debugf("failed to look up torrent file (follow rename): %v", err)
return
}

Expand All @@ -351,7 +351,7 @@ func (y *Cloud189PC) torrentFollowRename(folderId string, oldFileName string, ne
req.SetContext(context.Background()).SetQueryParams(queryParam)
}, nil, &RenameResp{}, isFamily)
if renameErr != nil {
utils.Log.Warnf("跟随重命名 torrent 文件失败: %v", renameErr)
utils.Log.Warnf("failed to rename torrent file (follow): %v", renameErr)
}
}()
}
Loading
Loading