875 lines
31 KiB
Go
875 lines
31 KiB
Go
package helpers
|
||
|
||
import (
|
||
"context"
|
||
"crypto/md5"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/spf13/cobra"
|
||
"golang.org/x/text/unicode/norm"
|
||
)
|
||
|
||
// ==========================================================
|
||
// drive push — 把本地文件夹镜像到钉盘(本地 → Drive)
|
||
// ==========================================================
|
||
// ──────────────────────────────────────────────────────────
|
||
// dws drive push — 把本地目录单向、文件级镜像到钉盘(本地 → Drive)
|
||
//
|
||
// 递归遍历 --local-folder 下所有常规文件与子目录(含空目录),按相对路径在
|
||
// --remote-folder 指向的钉盘文件夹里新建/覆盖/跳过。已存在的远端目录复用其
|
||
// fileId、不重建;缺失的目录按需 create_folder。文件按 --if-exists 决定
|
||
// skip / smart / overwrite。summary.failed > 0 时以非零退出码退出(结构化
|
||
// summary + items 仍打印在 stdout 上)。
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
// push 动作分类。
|
||
const (
|
||
pushActionUploaded = "uploaded" // 新建上传
|
||
pushActionOverwritten = "overwritten" // 覆盖已存在的远端文件
|
||
pushActionSkipped = "skipped" // 按 --if-exists 跳过
|
||
pushActionFolderCreated = "folder_created" // 新建远端目录(不计入 uploaded)
|
||
pushActionFailed = "failed"
|
||
|
||
pushActionPlannedUpload = "planned_upload"
|
||
pushActionPlannedOverwrite = "planned_overwrite"
|
||
pushActionPlannedSkip = "planned_skip"
|
||
pushActionPlannedFolderCreate = "planned_folder_create"
|
||
)
|
||
|
||
// localPushFile 描述一个待推送的本地常规文件。
|
||
type localPushFile struct {
|
||
RelPath string
|
||
AbsPath string
|
||
ModTimeMillis int64
|
||
Size int64
|
||
scanInfo os.FileInfo
|
||
}
|
||
|
||
// pushPutOpenedFile 把已由固定 os.Root 打开的本地文件句柄上传到 OSS。生产 push/sync
|
||
// 不再把可被并发换链的绝对路径交给 HTTP 层重新打开;测试必须通过 testseam.Swap 替换。
|
||
var (
|
||
pushPutOpenedFile = defaultPushPutOpenedFile
|
||
pushStatOpenedFile = func(file *os.File) (os.FileInfo, error) {
|
||
return file.Stat()
|
||
}
|
||
pushVerifyPinnedSourceRoot = func(root *pinnedPullRoot) error {
|
||
return root.verify()
|
||
}
|
||
pushMD5OpenedFile = func(file *os.File) (string, error) {
|
||
hash := md5.New()
|
||
if _, err := io.Copy(hash, file); err != nil {
|
||
return "", err
|
||
}
|
||
return base64.StdEncoding.EncodeToString(hash.Sum(nil)), nil
|
||
}
|
||
)
|
||
|
||
// drivePushItem 是输出 items[] 中每个条目的明细。
|
||
type drivePushItem struct {
|
||
RelPath string `json:"rel_path"`
|
||
Action string `json:"action"`
|
||
SizeBytes *int64 `json:"size_bytes,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// drivePushSummary 是各动作的计数汇总。uploaded 同时统计新建与覆盖。
|
||
type drivePushSummary struct {
|
||
Uploaded int `json:"uploaded"`
|
||
Skipped int `json:"skipped"`
|
||
Failed int `json:"failed"`
|
||
Aborted bool `json:"aborted"`
|
||
PlannedUploads int `json:"planned_uploads,omitempty"`
|
||
PlannedSkips int `json:"planned_skips,omitempty"`
|
||
PlannedFolders int `json:"planned_folders,omitempty"`
|
||
}
|
||
|
||
type drivePushResult struct {
|
||
Summary drivePushSummary `json:"summary"`
|
||
Items []drivePushItem `json:"items"`
|
||
}
|
||
|
||
type drivePushDryRunResult struct {
|
||
DryRun bool `json:"dry_run"`
|
||
Executed bool `json:"executed"`
|
||
PreviewKind string `json:"preview_kind"`
|
||
Operation string `json:"operation"`
|
||
IfExists string `json:"if_exists"`
|
||
Plan drivePushResult `json:"plan"`
|
||
}
|
||
|
||
// drivePushFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout,
|
||
// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明。
|
||
type drivePushFailure struct{ failed int }
|
||
|
||
func (e *drivePushFailure) Error() string {
|
||
return fmt.Sprintf("drive push: %d file(s) failed", e.failed)
|
||
}
|
||
func (e *drivePushFailure) RawStderr() string { return e.Error() }
|
||
func (e *drivePushFailure) ExitCode() int { return 1 }
|
||
|
||
func runDrivePush(cmd *cobra.Command, _ []string) error {
|
||
if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil {
|
||
return err
|
||
}
|
||
localDir := mustGetFlag(cmd, "local-folder")
|
||
remoteDirID := mustGetFlag(cmd, "remote-folder")
|
||
spaceID := mustGetFlag(cmd, "space-id")
|
||
|
||
// push 默认 skip(安全,只新增不覆盖)。
|
||
ifExists, _ := cmd.Flags().GetString("if-exists")
|
||
if ifExists == "" {
|
||
ifExists = ifExistsSkip
|
||
}
|
||
switch ifExists {
|
||
case ifExistsSkip, ifExistsSmart, ifExistsOverwrite:
|
||
default:
|
||
return fmt.Errorf("--if-exists 取值非法: %s(可选 skip|smart|overwrite)", ifExists)
|
||
}
|
||
|
||
absDir, err := validateLocalDirAbs(localDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
pinnedRoot, err := openExistingPinnedPullRoot(absDir)
|
||
if err != nil {
|
||
return fmt.Errorf("扫描本地目录失败: %w", err)
|
||
}
|
||
defer pinnedRoot.close()
|
||
|
||
localDirs, localFiles, err := runDriveMirrorWalkLocalForPushPinned(pinnedRoot)
|
||
if err != nil {
|
||
return fmt.Errorf("扫描本地目录失败: %w", err)
|
||
}
|
||
|
||
ctx := cmd.Context()
|
||
// 远端现状:已存在的文件(用于 --if-exists 判断)与目录(rel_path → fileId,用于复用/定位父目录)。
|
||
remoteFiles, remoteFolders, err := fetchRemoteTreeForPush(ctx, spaceID, remoteDirID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
preflight := buildMirrorPreflight(localDirs, localFiles, remoteFiles, remoteFolders)
|
||
|
||
res := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))}
|
||
if deps.Caller.DryRun() {
|
||
return printDrivePushDryRunWithPreflight(ifExists, remoteFiles, remoteFolders, localDirs, localFiles, preflight)
|
||
}
|
||
if len(preflight) > 0 {
|
||
appendDrivePushPreflightFailures(&res, preflight)
|
||
if perr := deps.Out.PrintJSON(res); perr != nil {
|
||
return perr
|
||
}
|
||
return &drivePushFailure{failed: res.Summary.Failed}
|
||
}
|
||
// 本地扫描与远端读取都可能耗时。首个远端写之前确认命令行路径仍指向最初固定
|
||
// 的根;若根已被移走或替换,整批中止,不能把替代目录的数据当成扫描快照上传。
|
||
if err := pinnedRoot.verify(); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 第一阶段:按需创建远端目录(浅层在前,保证父目录先于子目录存在)。
|
||
for _, dir := range localDirs {
|
||
if _, ok := remoteFolders[dir]; ok {
|
||
continue // 远端已存在,复用 fileId,不留痕
|
||
}
|
||
parentRel, name := splitRel(dir)
|
||
parentID, ok := remoteFolders[parentRel]
|
||
if !ok || parentID == "" {
|
||
res.Summary.Failed++
|
||
res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: "父目录未能创建"})
|
||
continue
|
||
}
|
||
if err := pinnedRoot.verify(); err != nil {
|
||
return err
|
||
}
|
||
fid, cerr := pushCreateFolder(ctx, spaceID, parentID, name)
|
||
if cerr != nil || fid == "" {
|
||
res.Summary.Failed++
|
||
msg := "create_folder 未返回 fileId"
|
||
if cerr != nil {
|
||
msg = cerr.Error()
|
||
}
|
||
res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: msg})
|
||
continue
|
||
}
|
||
remoteFolders[dir] = fid
|
||
res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated})
|
||
}
|
||
|
||
// 第二阶段:上传/覆盖/跳过文件。
|
||
for i := range localFiles {
|
||
lf := localFiles[i]
|
||
size := lf.Size
|
||
parentRel, name := splitRel(lf.RelPath)
|
||
parentID, ok := remoteFolders[parentRel]
|
||
if !ok || parentID == "" {
|
||
res.Summary.Failed++
|
||
res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
|
||
continue
|
||
}
|
||
|
||
rf, exists := remoteFiles[lf.RelPath]
|
||
action := pushActionUploaded
|
||
overwriteID := ""
|
||
if exists {
|
||
switch ifExists {
|
||
case ifExistsSkip:
|
||
res.Summary.Skipped++
|
||
res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionSkipped, SizeBytes: &size})
|
||
continue
|
||
case ifExistsSmart:
|
||
// 远端时间可信且已 ≥ 本地 → 跳过;否则走覆盖路径。
|
||
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
|
||
res.Summary.Skipped++
|
||
res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionSkipped, SizeBytes: &size})
|
||
continue
|
||
}
|
||
action = pushActionOverwritten
|
||
case ifExistsOverwrite:
|
||
action = pushActionOverwritten
|
||
}
|
||
// 覆盖分支必须走覆盖上传(传 overwriteFileId、不传 parentId),
|
||
// 否则会在同目录新建重名副本而非原地覆盖。
|
||
if action == pushActionOverwritten {
|
||
overwriteID = rf.FileID
|
||
}
|
||
}
|
||
|
||
if err := pushUploadFilePinned(ctx, spaceID, parentID, overwriteID, name, pinnedRoot, lf); err != nil {
|
||
res.Summary.Failed++
|
||
res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: err.Error()})
|
||
continue
|
||
}
|
||
res.Summary.Uploaded++ // uploaded 同时统计新建与覆盖
|
||
res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
|
||
}
|
||
|
||
// 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。
|
||
if perr := deps.Out.PrintJSON(res); perr != nil {
|
||
return perr
|
||
}
|
||
if res.Summary.Failed > 0 {
|
||
return &drivePushFailure{failed: res.Summary.Failed}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// fetchRemoteTreeForPush 递归拉取 rootID 下的远端现状:files(rel_path → *remoteFile,
|
||
// 用于 --if-exists 判断)与 folders(rel_path → fileId,rel_path "" 即 rootID 本身)。
|
||
func fetchRemoteTreeForPush(ctx context.Context, spaceID, rootID string) (map[string]*remoteFile, map[string]string, error) {
|
||
files := make(map[string]*remoteFile)
|
||
folders := map[string]string{"": rootID}
|
||
occupied := make(map[string]string)
|
||
if err := walkRemoteForPush(ctx, spaceID, rootID, "", files, folders, occupied, 0); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return files, folders, nil
|
||
}
|
||
|
||
func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, files map[string]*remoteFile, folders map[string]string, occupied map[string]string, depth int) error {
|
||
if depth > remoteMaxDepth {
|
||
return fmt.Errorf("drive 目录层级超过 %d 层,疑似循环引用,已中止", remoteMaxDepth)
|
||
}
|
||
nextToken := ""
|
||
seenTokens := make(map[string]struct{})
|
||
for {
|
||
args := map[string]any{"maxResults": float64(driveListPageSize)}
|
||
if spaceID != "" {
|
||
args["spaceId"] = spaceID
|
||
}
|
||
if parentID != "" {
|
||
args["parentId"] = parentID
|
||
}
|
||
if nextToken != "" {
|
||
args["nextToken"] = nextToken
|
||
}
|
||
|
||
text, err := callDriveListFiles(ctx, args)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
items, token, err := parseDriveList(text)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for _, it := range items {
|
||
name, included, err := remoteMirrorEntryName(it, relBase)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !included {
|
||
continue
|
||
}
|
||
childRel := name
|
||
if relBase != "" {
|
||
childRel = relBase + "/" + name
|
||
}
|
||
if it.isFolder() {
|
||
if err := claimRemotePath(occupied, childRel, "目录"); err != nil {
|
||
return err
|
||
}
|
||
folderID := it.id()
|
||
if folderID == "" {
|
||
return fmt.Errorf("远端目录 %q 缺少目录 ID,已中止递归", childRel)
|
||
}
|
||
folders[childRel] = folderID
|
||
if err := walkRemoteForPush(ctx, spaceID, folderID, childRel, files, folders, occupied, depth+1); err != nil {
|
||
return err
|
||
}
|
||
continue
|
||
}
|
||
// 非文件类型已由 remoteMirrorEntryName 过滤;目录在上方完成递归后返回。
|
||
if err := claimRemotePath(occupied, childRel, "文件"); err != nil {
|
||
return err
|
||
}
|
||
fileID := it.id()
|
||
if fileID == "" {
|
||
return fmt.Errorf("远端文件 %q 缺少文件 ID,已中止遍历", childRel)
|
||
}
|
||
modMillis, modValid := it.modifiedMillis()
|
||
files[childRel] = &remoteFile{
|
||
RelPath: childRel,
|
||
FileID: fileID,
|
||
Hash: it.hash(),
|
||
ModifiedTime: modMillis,
|
||
ModifiedTimeValid: modValid,
|
||
}
|
||
}
|
||
|
||
if token == "" {
|
||
break
|
||
}
|
||
if _, exists := seenTokens[token]; exists {
|
||
return fmt.Errorf("远端目录 %q 的分页 token 重复,疑似分页循环,已中止", relBase)
|
||
}
|
||
seenTokens[token] = struct{}{}
|
||
nextToken = token
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, remoteFolders map[string]string, localDirs []string, localFiles []localPushFile) error {
|
||
return printDrivePushDryRunWithPreflight(ifExists, remoteFiles, remoteFolders, localDirs, localFiles,
|
||
buildMirrorPreflight(localDirs, localFiles, remoteFiles, remoteFolders))
|
||
}
|
||
|
||
func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*remoteFile, remoteFolders map[string]string, localDirs []string, localFiles []localPushFile, preflight map[string]string) error {
|
||
plan := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))}
|
||
if len(preflight) > 0 {
|
||
appendDrivePushPreflightFailures(&plan, preflight)
|
||
return deps.Out.PrintJSON(drivePushDryRunResult{
|
||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive push",
|
||
IfExists: ifExists, Plan: plan,
|
||
})
|
||
}
|
||
plannedFolders := make(map[string]string, len(remoteFolders)+len(localDirs))
|
||
for rel, fileID := range remoteFolders {
|
||
plannedFolders[rel] = fileID
|
||
}
|
||
for _, dir := range localDirs {
|
||
if _, ok := plannedFolders[dir]; ok {
|
||
continue
|
||
}
|
||
parentRel, _ := splitRel(dir)
|
||
if plannedFolders[parentRel] == "" {
|
||
plan.Summary.Failed++
|
||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: "父目录未能创建"})
|
||
continue
|
||
}
|
||
plannedFolders[dir] = "dry-run-planned-folder"
|
||
plan.Summary.PlannedFolders++
|
||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionPlannedFolderCreate})
|
||
}
|
||
for _, lf := range localFiles {
|
||
size := lf.Size
|
||
parentRel, _ := splitRel(lf.RelPath)
|
||
if plannedFolders[parentRel] == "" {
|
||
plan.Summary.Failed++
|
||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
|
||
continue
|
||
}
|
||
action := pushActionPlannedUpload
|
||
if rf, exists := remoteFiles[lf.RelPath]; exists {
|
||
switch ifExists {
|
||
case ifExistsSkip:
|
||
action = pushActionPlannedSkip
|
||
case ifExistsSmart:
|
||
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
|
||
action = pushActionPlannedSkip
|
||
} else {
|
||
action = pushActionPlannedOverwrite
|
||
}
|
||
case ifExistsOverwrite:
|
||
action = pushActionPlannedOverwrite
|
||
}
|
||
}
|
||
if action == pushActionPlannedSkip {
|
||
plan.Summary.PlannedSkips++
|
||
} else {
|
||
plan.Summary.PlannedUploads++
|
||
}
|
||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
|
||
}
|
||
return deps.Out.PrintJSON(drivePushDryRunResult{
|
||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive push",
|
||
IfExists: ifExists, Plan: plan,
|
||
})
|
||
}
|
||
|
||
func appendDrivePushPreflightFailures(res *drivePushResult, preflight map[string]string) {
|
||
res.Summary.Aborted = true
|
||
for _, rel := range mirrorPreflightFailureRels(preflight) {
|
||
res.Summary.Failed++
|
||
res.Items = append(res.Items, drivePushItem{RelPath: rel, Action: pushActionFailed, Error: preflight[rel]})
|
||
}
|
||
}
|
||
|
||
// pushTypeConflictError 在任何写操作或 dry-run 成功预览之前,拒绝同一路径下
|
||
// “本地目录 ↔ 远端文件”或“本地文件 ↔ 远端目录”的类型冲突。
|
||
func pushTypeConflictError(rel string, localIsFolder bool, remoteFiles map[string]*remoteFile, remoteFolders map[string]string) string {
|
||
if localIsFolder {
|
||
if _, exists := remoteFiles[rel]; exists {
|
||
return "远端同路径已存在文件,无法创建目录"
|
||
}
|
||
return ""
|
||
}
|
||
if _, exists := remoteFolders[rel]; exists {
|
||
return "远端同路径已存在目录,无法上传文件"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// walkLocalForPush 遍历本地根目录,返回所有子目录 rel_path(不含根本身,浅层在前)
|
||
// 与所有常规文件。dirs 升序排序保证父目录先于子目录被创建。
|
||
func walkLocalForPush(root string) ([]string, []localPushFile, error) {
|
||
pinned, err := openExistingPinnedPullRoot(root)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
defer pinned.close()
|
||
return walkLocalForPushPinned(pinned)
|
||
}
|
||
|
||
// 文件系统遍历与命令入口通过显式 seam 暴露仅用于确定性覆盖 I/O 失败;测试必须用
|
||
// testseam.Swap 替换,生产仍直接使用 fs.WalkDir / walkLocalForPushPinned。
|
||
var (
|
||
walkPinnedLocalFS = fs.WalkDir
|
||
runDriveMirrorWalkLocalForPushPinned = walkLocalForPushPinned
|
||
)
|
||
|
||
// walkLocalForPushPinned 从命令开始时固定的同一个 os.Root/FS 扫描本地树。即使调用方
|
||
// 给出的路径随后被替换成软链或另一目录,遍历也只会读取原目录树。
|
||
func walkLocalForPushPinned(root *pinnedPullRoot) ([]string, []localPushFile, error) {
|
||
if err := root.verify(); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
var dirs []string
|
||
var files []localPushFile
|
||
err := walkPinnedLocalFS(root.root.FS(), ".", func(path string, d fs.DirEntry, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if path == "." {
|
||
return nil
|
||
}
|
||
relSlash := filepath.ToSlash(path)
|
||
if d.IsDir() {
|
||
dirs = append(dirs, relSlash)
|
||
return nil
|
||
}
|
||
info, infoErr := d.Info()
|
||
if infoErr != nil {
|
||
return infoErr
|
||
}
|
||
if !info.Mode().IsRegular() {
|
||
return nil
|
||
}
|
||
files = append(files, localPushFile{
|
||
RelPath: relSlash, AbsPath: filepath.Join(root.absDir, filepath.FromSlash(relSlash)),
|
||
ModTimeMillis: info.ModTime().UnixMilli(), Size: info.Size(), scanInfo: info,
|
||
})
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if err := root.verify(); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
// 字典序即浅层在前("a" < "a/b" < "a/b/c"),父目录先于子目录。
|
||
sort.Strings(dirs)
|
||
sort.Slice(files, func(i, j int) bool { return files[i].RelPath < files[j].RelPath })
|
||
return dirs, files, nil
|
||
}
|
||
|
||
// judgePinnedPushFileMatch 与 judgeFileMatch 的判定保持一致,但 exact 模式只从固定
|
||
// os.Root 打开的句柄计算 MD5,绝不按可被换链的 AbsPath 重开文件。
|
||
func judgePinnedPushFileMatch(root *pinnedPullRoot, local localPushFile, remote *remoteFile, quick bool) (string, error) {
|
||
if quick {
|
||
if remote.ModifiedTimeValid && local.ModTimeMillis == remote.ModifiedTime {
|
||
return matchUnchanged, nil
|
||
}
|
||
return matchModified, nil
|
||
}
|
||
if remote.Hash == "" {
|
||
return matchUnknown, nil
|
||
}
|
||
file, err := root.root.Open(filepath.FromSlash(local.RelPath))
|
||
if err != nil {
|
||
return "", fmt.Errorf("计算 %s 的 MD5 失败: %w", local.RelPath, err)
|
||
}
|
||
defer file.Close()
|
||
info, err := pushStatOpenedFile(file)
|
||
if err != nil || !info.Mode().IsRegular() || (local.scanInfo != nil && !os.SameFile(local.scanInfo, info)) {
|
||
return "", fmt.Errorf("计算 %s 的 MD5 失败: 本地文件在扫描后被替换", local.RelPath)
|
||
}
|
||
hash, err := pushMD5OpenedFile(file)
|
||
if err != nil {
|
||
return "", fmt.Errorf("计算 %s 的 MD5 失败: %w", local.RelPath, err)
|
||
}
|
||
if err := pushVerifyPinnedSourceRoot(root); err != nil {
|
||
return "", err
|
||
}
|
||
if hash == remote.Hash {
|
||
return matchUnchanged, nil
|
||
}
|
||
return matchModified, nil
|
||
}
|
||
|
||
// mirrorPathKey 表示 Drive 镜像契约中的等价路径:逐段 Unicode NFC + 大小写折叠。
|
||
// 该规则独立于运行命令的本地文件系统,避免 macOS/Windows 与 Drive 间把 a/A 或
|
||
// NFC/NFD 当成“双向新增”,也避免把等价目录前缀错误拼接成另一棵树。
|
||
func mirrorPathKey(rel string) string {
|
||
parts := strings.Split(rel, "/")
|
||
for i := range parts {
|
||
parts[i] = norm.NFC.String(strings.ToLower(parts[i]))
|
||
}
|
||
return strings.Join(parts, "/")
|
||
}
|
||
|
||
func mirrorLocalRelError(rel string) string {
|
||
for _, segment := range strings.Split(rel, "/") {
|
||
if !isSafeRemoteSegment(segment) || strings.IndexByte(segment, 0) >= 0 {
|
||
return fmt.Sprintf("本地路径 %q 含无法安全映射到远端的名称段 %q,已中止镜像", rel, segment)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// buildMirrorPreflight 在任何远端写之前统一检查本地名称和双端等价路径/类型。
|
||
// 每个条目同时声明全部祖先目录,因而 local A/x 与 remote a/y 会在祖先键上冲突;
|
||
// 即便本地为空,remote A/a 也会被完整性门禁识别。调用方必须整体拒绝写入,不能只
|
||
// 跳过冲突项。
|
||
func buildMirrorPreflight(localDirs []string, localFiles []localPushFile, remoteFiles map[string]*remoteFile, remoteFolders map[string]string) map[string]string {
|
||
type mirrorKind string
|
||
const (
|
||
mirrorFile mirrorKind = "文件"
|
||
mirrorDir mirrorKind = "目录"
|
||
)
|
||
type entry struct {
|
||
rel string
|
||
kind mirrorKind
|
||
}
|
||
local := make(map[string]map[entry]struct{})
|
||
remote := make(map[string]map[entry]struct{})
|
||
failures := make(map[string]string)
|
||
addClaims := func(index map[string]map[entry]struct{}, rel string, kind mirrorKind) {
|
||
parts := strings.Split(rel, "/")
|
||
for i := range parts {
|
||
claimKind := mirrorDir
|
||
if i == len(parts)-1 {
|
||
claimKind = kind
|
||
}
|
||
claimRel := strings.Join(parts[:i+1], "/")
|
||
key := mirrorPathKey(claimRel)
|
||
if index[key] == nil {
|
||
index[key] = make(map[entry]struct{})
|
||
}
|
||
index[key][entry{rel: claimRel, kind: claimKind}] = struct{}{}
|
||
}
|
||
}
|
||
addLocal := func(rel string, kind mirrorKind) {
|
||
if msg := mirrorLocalRelError(rel); msg != "" {
|
||
failures[rel] = msg
|
||
}
|
||
addClaims(local, rel, kind)
|
||
}
|
||
for _, rel := range localDirs {
|
||
addLocal(rel, mirrorDir)
|
||
}
|
||
for _, f := range localFiles {
|
||
addLocal(f.RelPath, mirrorFile)
|
||
}
|
||
for rel := range remoteFolders {
|
||
if rel != "" {
|
||
addClaims(remote, rel, mirrorDir)
|
||
}
|
||
}
|
||
for rel := range remoteFiles {
|
||
addClaims(remote, rel, mirrorFile)
|
||
}
|
||
keys := make(map[string]struct{}, len(local)+len(remote))
|
||
for key := range local {
|
||
keys[key] = struct{}{}
|
||
}
|
||
for key := range remote {
|
||
keys[key] = struct{}{}
|
||
}
|
||
for key := range keys {
|
||
claims := make(map[entry]struct{}, len(local[key])+len(remote[key]))
|
||
for claim := range local[key] {
|
||
claims[claim] = struct{}{}
|
||
}
|
||
for claim := range remote[key] {
|
||
claims[claim] = struct{}{}
|
||
}
|
||
if len(claims) <= 1 {
|
||
continue
|
||
}
|
||
for claim := range claims {
|
||
failures[claim.rel] = fmt.Sprintf("%s路径 %q 在镜像规则下存在大小写/Unicode NFC 等价路径拼写或类型歧义,已中止镜像", claim.kind, claim.rel)
|
||
}
|
||
}
|
||
return failures
|
||
}
|
||
|
||
func mirrorPreflightFailureRels(preflight map[string]string) []string {
|
||
rels := make([]string, 0, len(preflight))
|
||
for rel := range preflight {
|
||
rels = append(rels, rel)
|
||
}
|
||
sort.Strings(rels)
|
||
return rels
|
||
}
|
||
|
||
// walkLocalForPushEntry 是 walkLocalForPush 的单条目处理逻辑。抽成命名函数只为可测:
|
||
// filepath.Rel / Info() 的失败在真实 WalkDir 下几乎不可复现,单测可直接注入。
|
||
func walkLocalForPushEntry(root, path string, d fs.DirEntry, err error, dirs *[]string, files *[]localPushFile) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rel, rerr := filepath.Rel(root, path)
|
||
if rerr != nil {
|
||
return rerr
|
||
}
|
||
if rel == "." {
|
||
return nil // 根目录本身不处理
|
||
}
|
||
relSlash := filepath.ToSlash(rel)
|
||
if d.IsDir() {
|
||
*dirs = append(*dirs, relSlash)
|
||
return nil
|
||
}
|
||
info, ierr := d.Info()
|
||
if ierr != nil {
|
||
return ierr
|
||
}
|
||
// 只推送常规文件;符号链接、设备文件等忽略。
|
||
if !info.Mode().IsRegular() {
|
||
return nil
|
||
}
|
||
*files = append(*files, localPushFile{
|
||
RelPath: relSlash,
|
||
AbsPath: path,
|
||
ModTimeMillis: info.ModTime().UnixMilli(),
|
||
Size: info.Size(),
|
||
scanInfo: info,
|
||
})
|
||
return nil
|
||
}
|
||
|
||
// pushCreateFolder 在 parentID 下创建名为 name 的文件夹,返回新目录的 fileId。
|
||
func pushCreateFolder(ctx context.Context, spaceID, parentID, name string) (string, error) {
|
||
args := map[string]any{"name": name}
|
||
if spaceID != "" {
|
||
args["spaceId"] = spaceID
|
||
}
|
||
if parentID != "" {
|
||
args["parentId"] = parentID
|
||
}
|
||
text, err := callMCPToolReturnText(ctx, "create_folder", args)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return parseNodeID(text), nil
|
||
}
|
||
|
||
// pushUploadFile 走 get_upload_info → OSS PUT → commit_upload 三步,把本地文件
|
||
// 上传到 parentID 目录下。复用 drive upload 的凭证解析与 HTTP PUT。
|
||
//
|
||
// overwriteFileID 非空时走覆盖流程(与 uploadToDrive 一致):get_upload_info 与
|
||
// commit_upload 两个阶段都传 overwriteFileId、都不传 parentId(服务端据此设置
|
||
// conflictStrategy=OVERWRITE,原地覆盖而非在同目录新建重名副本)。
|
||
func pushUploadFile(ctx context.Context, spaceID, parentID, overwriteFileID, fileName, filePath string, fileSize int64) error {
|
||
return pushUploadWithTransport(ctx, spaceID, parentID, overwriteFileID, fileName, fileSize,
|
||
func(resourceURL string, ossHeaders map[string]string) error {
|
||
return httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize)
|
||
})
|
||
}
|
||
|
||
// pushUploadFilePinned 从固定本地根打开 rel_path,并在发出 get_upload_info 前核对
|
||
// 文件身份与扫描快照。HTTP PUT 只读取这个已打开句柄,绝不按 AbsPath 二次解析。
|
||
func pushUploadFilePinned(ctx context.Context, spaceID, parentID, overwriteFileID, fileName string, root *pinnedPullRoot, local localPushFile) error {
|
||
if err := root.verify(); err != nil {
|
||
return err
|
||
}
|
||
file, err := root.root.Open(filepath.FromSlash(local.RelPath))
|
||
if err != nil {
|
||
return fmt.Errorf("打开本地上传文件失败: %w", err)
|
||
}
|
||
defer file.Close()
|
||
info, err := pushStatOpenedFile(file)
|
||
if err != nil {
|
||
return fmt.Errorf("读取本地上传文件身份失败: %w", err)
|
||
}
|
||
if !info.Mode().IsRegular() {
|
||
return fmt.Errorf("本地上传目标 %q 已不再是常规文件", local.RelPath)
|
||
}
|
||
if local.scanInfo != nil && !os.SameFile(local.scanInfo, info) {
|
||
return fmt.Errorf("本地上传目标 %q 在扫描后被替换,已中止上传", local.RelPath)
|
||
}
|
||
if info.Size() != local.Size || info.ModTime().UnixMilli() != local.ModTimeMillis {
|
||
return fmt.Errorf("本地上传目标 %q 在扫描后发生变化,已中止上传", local.RelPath)
|
||
}
|
||
if err := pushVerifyPinnedSourceRoot(root); err != nil {
|
||
return err
|
||
}
|
||
return pushUploadWithTransport(ctx, spaceID, parentID, overwriteFileID, fileName, local.Size,
|
||
func(resourceURL string, ossHeaders map[string]string) error {
|
||
if err := pushPutOpenedFile(ctx, resourceURL, ossHeaders, file, local.Size); err != nil {
|
||
return err
|
||
}
|
||
// OSS 传输期间若源文件被原地改写(编辑器覆盖、截断重写、mmap),
|
||
// PUT 里发送的可能是新旧内容的混合体,root.verify() 又不检查文件本身。
|
||
// commit 前对已打开句柄再取一次身份,与 PUT 前的 inode/size/mtime 逐项比对;
|
||
// 任一变化都拒绝 commit,避免 overwrite/local-wins 场景把混合内容覆盖到远端。
|
||
after, err := pushStatOpenedFile(file)
|
||
if err != nil {
|
||
return fmt.Errorf("上传后读取本地上传文件身份失败: %w", err)
|
||
}
|
||
if !os.SameFile(info, after) ||
|
||
after.Size() != info.Size() ||
|
||
after.ModTime().UnixMilli() != info.ModTime().UnixMilli() {
|
||
return fmt.Errorf("本地上传目标 %q 在传输期间被修改,已中止提交", local.RelPath)
|
||
}
|
||
// OSS 传输期间若命令行根已被替换,不提交这个上传会话;已发送的字节仍来自
|
||
// 固定根内的原文件句柄,不可能读取替代路径或根外内容。
|
||
return root.verify()
|
||
})
|
||
}
|
||
|
||
func pushUploadWithTransport(ctx context.Context, spaceID, parentID, overwriteFileID, fileName string, fileSize int64, put func(string, map[string]string) error) error {
|
||
step1 := map[string]any{"fileName": fileName, "fileSize": float64(fileSize)}
|
||
if spaceID != "" {
|
||
step1["spaceId"] = spaceID
|
||
}
|
||
if overwriteFileID != "" {
|
||
step1["overwriteFileId"] = overwriteFileID
|
||
} else if parentID != "" {
|
||
step1["parentId"] = parentID
|
||
}
|
||
text, err := callMCPToolReturnText(ctx, "get_upload_info", step1)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resourceURL, uploadID, ossHeaders, err := parseDriveUploadInfo(text)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := put(resourceURL, ossHeaders); err != nil {
|
||
return err
|
||
}
|
||
commit := map[string]any{"fileName": fileName, "fileSize": float64(fileSize), "uploadId": uploadID}
|
||
if spaceID != "" {
|
||
commit["spaceId"] = spaceID
|
||
}
|
||
if overwriteFileID != "" {
|
||
commit["overwriteFileId"] = overwriteFileID
|
||
} else if parentID != "" {
|
||
commit["parentId"] = parentID
|
||
}
|
||
commitText, err := callMCPToolReturnText(ctx, "commit_upload", commit)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if strings.TrimSpace(commitText) == "" {
|
||
return fmt.Errorf("commit_upload returned no business result; remote effect is unknown")
|
||
}
|
||
var result map[string]any
|
||
if err := json.Unmarshal([]byte(commitText), &result); err != nil {
|
||
return fmt.Errorf("parse commit_upload response: %w", err)
|
||
}
|
||
if len(result) == 0 {
|
||
return fmt.Errorf("commit_upload returned an empty JSON object; remote effect is unknown")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func defaultPushPutOpenedFile(ctx context.Context, url string, headers map[string]string, file *os.File, fileSize int64) error {
|
||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||
return fmt.Errorf("failed to seek upload file: %w", err)
|
||
}
|
||
// net/http owns and closes Request.Body after RoundTrip. The *os.File belongs
|
||
// to pushUploadFilePinned, which must stat it after PUT before commit_upload;
|
||
// wrap it in a no-op closer so HTTP completion cannot close that shared handle.
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, io.NopCloser(file))
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create upload request: %w", err)
|
||
}
|
||
req.ContentLength = fileSize
|
||
req.Header.Del("Content-Type")
|
||
for key, value := range headers {
|
||
req.Header.Set(key, value)
|
||
}
|
||
resp, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("file upload failed: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("OSS upload failed: %w", &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)})
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// parseNodeID 从 create_folder / commit 等返回里抽出节点 fileId(带 fallback)。
|
||
func parseNodeID(text string) string {
|
||
var data map[string]any
|
||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||
return ""
|
||
}
|
||
if r, ok := data["result"].(map[string]any); ok {
|
||
data = r
|
||
}
|
||
for _, k := range []string{"fileId", "dentryUuid", "dentryId", "id", "nodeId"} {
|
||
if s, ok := data[k].(string); ok && s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// splitRel 把 rel_path 拆成父路径与末段名:"a/b/c" → ("a/b","c"),"c" → ("","c")。
|
||
func splitRel(rel string) (string, string) {
|
||
if i := strings.LastIndex(rel, "/"); i >= 0 {
|
||
return rel[:i], rel[i+1:]
|
||
}
|
||
return "", rel
|
||
}
|
||
|
||
// checkFileNotDingTalkDoc 通过 get_file_info 检查文件类型,
|
||
// 若为钉钉在线文档 (adoc/axls/amind/adraw) 则返回错误,提示使用对应服务命令。
|
||
// 探测失败(如无效 fileId)不阻断流程,让后续 MCP 工具自行报错。
|