fix(strm): 115 元数据上传幂等,避免重复上传 (#29)

上传/重试前按父目录+文件名+SHA1 探活,已有同内容副本则跳过并清脏副本;
成功后回写新 file_id;同步扫描认可 30 分钟内同路径同大小的 done 任务,
缩短「任务已完成但列表未反映」导致的再入队窗口。

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
truewhile
2026-09-07 09:01:58 +08:00
committed by GitHub
parent 2e636ce43f
commit 6bf8a6878a
8 changed files with 501 additions and 72 deletions
+54
View File
@@ -119,6 +119,60 @@ func (c *OpenClient) GetFsListFlat(ctx context.Context, cid string, offset, limi
return files, resp.Count, nil
}
// FindNamedContentInParent 在父目录下查找与本地内容一致的同名文件。
// 匹配条件:文件名完全一致、大小一致,且 SHA1 为空或与 expectedSHA1 大小写不敏感相等。
// 同时返回该目录下全部同名文件(含未匹配的脏副本),便于上传前清理。
// 目录过大时分页扫描,最多拉取 maxListPages 页(每页 pageSize 条)。
func (c *OpenClient) FindNamedContentInParent(ctx context.Context, parentCID, fileName, expectedSHA1 string, expectedSize int64) (matched *RemoteFile, sameName []RemoteFile, err error) {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return nil, nil, nil
}
const pageSize = 200
const maxListPages = 20 // 最多扫描 4000 项,元数据父目录通常远小于此
expectedSHA1 = strings.TrimSpace(expectedSHA1)
offset := 0
for page := 0; page < maxListPages; page++ {
files, _, listErr := c.GetFsList(ctx, parentCID, offset, pageSize)
if listErr != nil {
return nil, sameName, listErr
}
if len(files) == 0 {
break
}
for i := range files {
f := files[i]
if f.Category == TypeDir {
continue
}
// fta=0/2 表示未上传完成,不可作为已存在副本
if f.Fta == "0" || f.Fta == "2" {
continue
}
if f.FileName != fileName {
continue
}
sameName = append(sameName, f)
if matched != nil {
continue
}
if f.FileSize != expectedSize {
continue
}
remoteSha := strings.TrimSpace(f.Sha1)
if remoteSha == "" || remoteSha == "-" || strings.EqualFold(remoteSha, expectedSHA1) {
cp := f
matched = &cp
}
}
if len(files) < pageSize {
break
}
offset += len(files)
}
return matched, sameName, nil
}
// GetFsDetailByCid 查询文件(夹)详情。
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
params := map[string]string{"file_id": fileId}
@@ -0,0 +1,54 @@
package cloud115
import (
"context"
"fmt"
"net/http"
"testing"
)
func TestFindNamedContentInParentMatchAndSameName(t *testing.T) {
sha := "AABBCCDDEEFF00112233445566778899AABBCCDD"
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/ufile/files" {
t.Errorf("unexpected path %s", r.URL.Path)
}
w.Write([]byte(fmt.Sprintf(`{"state":true,"data":[
{"fid":"dir-1","fc":"0","fn":"sub","fs":0},
{"fid":"keep","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"1"},
{"fid":"dirty","fc":"1","fn":"a.nfo","fs":10,"sha1":"OTHER","fta":"1"},
{"fid":"other","fc":"1","fn":"b.nfo","fs":10,"sha1":%q,"fta":"1"},
{"fid":"incomplete","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"0"}
]}`, sha, sha, sha)))
})
c := NewOpenClient("100195125", "at1", "rt1")
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", sha, 10)
if err != nil {
t.Fatal(err)
}
if matched == nil || matched.FileId != "keep" {
t.Fatalf("matched = %+v, want keep", matched)
}
if len(sameName) != 2 { // keep + dirty;incomplete 被 fta 过滤
t.Fatalf("sameName len=%d, want 2 (incomplete excluded)", len(sameName))
}
}
func TestFindNamedContentInParentNoMatch(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"state":true,"data":[
{"fid":"x","fc":"1","fn":"a.nfo","fs":9,"sha1":"OTHER","fta":"1"}
]}`))
})
c := NewOpenClient("100195125", "at1", "rt1")
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", "WANT", 10)
if err != nil {
t.Fatal(err)
}
if matched != nil {
t.Fatalf("expected no match, got %+v", matched)
}
if len(sameName) != 1 {
t.Fatalf("sameName should still list name hits, got %d", len(sameName))
}
}