Files
dingtalk-workspace-cli/internal/helpers/drive_push.go

875 lines
31 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 工具自行报错。