Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3210232876 | ||
|
|
162a2eb0a7 | ||
|
|
d3f62193e7 | ||
|
|
1a11c687ed | ||
|
|
dfed4ba37d | ||
|
|
f26df04679 | ||
|
|
d02b03436d | ||
|
|
bc7b96ba5f | ||
|
|
9539c887f6 | ||
|
|
6607f44724 | ||
|
|
837a96fe3d | ||
|
|
fdcd44f9e3 | ||
|
|
34c0c86a59 | ||
|
|
b1f4a5d62a | ||
|
|
bf33ab622f |
@@ -6,6 +6,38 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
|
||||
|
||||
## [1.0.56] - 2026-08-04
|
||||
|
||||
This stable release promotes the fully delivered `v1.0.56-beta.4` baseline.
|
||||
It includes PR #852's resilient multipart Drive download implementation,
|
||||
together with the v1.0.56 beta-line command, Schema, Skill, and runtime
|
||||
improvements already validated through the prerelease channel.
|
||||
|
||||
### Added
|
||||
|
||||
- **Resilient multipart Drive downloads** (#852) — `drive download` and
|
||||
`drive download-version` support parallel chunk transfer, Range probing,
|
||||
fingerprint-validated checkpoint resume, automatic 401/403 credential
|
||||
refresh, and graceful interruption with checkpoint preservation.
|
||||
|
||||
## [1.0.56-beta.4] - 2026-08-04
|
||||
|
||||
This beta adds PR #852 on top of v1.0.56-beta.3. It makes Drive downloads
|
||||
resilient for large files through parallel transfer, validated resumable
|
||||
checkpoints, and automatic credential refresh.
|
||||
|
||||
### Added
|
||||
|
||||
- **Multipart Drive downloads** (#852) — adds `--part-size`, `--parallel`, and
|
||||
`--no-resume` to `drive download` and `drive download-version`. Files above
|
||||
the part-size threshold use a Range probe and parallel chunks, resume from a
|
||||
fingerprint-validated checkpoint, refresh credentials on 401/403, and keep
|
||||
the checkpoint when Ctrl+C interrupts a transfer.
|
||||
|
||||
## [1.0.56-beta.3] - 2026-08-03
|
||||
|
||||
This beta adds PRs #846 and #851 on top of v1.0.56-beta.2. It adds a
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.56-beta.3"
|
||||
version "1.0.56-beta.4"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5d35bb3fca7883a4ee51e1561aefd6b954b104313359a7c05b30a333ea41e749"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "5a94069a3ab2c811d915639bbfd9ff17035b77501d5f9070c0b540ffe525a41b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "e9cbc1c647f3ea703e1b93264c0e7d92871cfabe26fef26fbe17b9dff4b80c0f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "7c475841ea871d204f9f6efc7d6e5378cf19db4541aeaa8a27d1387fa6f2a1f1"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-skills.zip"
|
||||
sha256 "b29b35345613bc9260ae37578eb982472591c9dc548b02176b5b9ba0bdc431f2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
|
||||
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.55"
|
||||
version "1.0.56"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-arm64.tar.gz"
|
||||
sha256 "dd753bbd051e5dd007cf433b8aa211c4a221dd73dfcb0b3783fa924d09f12351"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-amd64.tar.gz"
|
||||
sha256 "f465eb7ac38a8a84eac4eb821fd15424bfc6f6245a60fa695ba97a639970dd77"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz"
|
||||
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-arm64.tar.gz"
|
||||
sha256 "5961be0fd551ec8e69b6fff2b1609f73486f7e6c3ffe8eb4bb99fa1ed691b401"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz"
|
||||
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-amd64.tar.gz"
|
||||
sha256 "051ba404a5f6a8fb15def0e0f5d9d273cf9d63f881df2fffe159f2c4ea3366e7"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz"
|
||||
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-skills.zip"
|
||||
sha256 "bd35f674f184001f5a03c7b5fa6029ebcda54f0054e15cd608b5b5e213ce2d05"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip"
|
||||
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b",
|
||||
"source_hash": "sha256:f3d5e5d6ca3c613b1d004d68eca89679d0f8cffe882d7fed0fbb1bcef078fdc9",
|
||||
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
|
||||
"coverage": {
|
||||
"surface_products": 26,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b",
|
||||
"source_hash": "sha256:f3d5e5d6ca3c613b1d004d68eca89679d0f8cffe882d7fed0fbb1bcef078fdc9",
|
||||
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
|
||||
"source_files": 160,
|
||||
"hint_files": 54,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
|
||||
"source_hash": "sha256:1b4f0e6e6fe49115137ff81717462374b2fdd05ad4ca1bfb1b2dd8272aa6bad8",
|
||||
"source_hash": "sha256:00285e7f05c4ed9e9e3aa7464c7114990187dd7ffdcc9e83e64f3ec33d22d0a8",
|
||||
"catalog": {
|
||||
"agent_metadata": {
|
||||
"products_with_metadata": 26,
|
||||
"source": "embedded-skill-metadata",
|
||||
"source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b",
|
||||
"source_hash": "sha256:f3d5e5d6ca3c613b1d004d68eca89679d0f8cffe882d7fed0fbb1bcef078fdc9",
|
||||
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
|
||||
"surface_products": 26,
|
||||
"surface_tools": 846,
|
||||
|
||||
@@ -5312,8 +5312,99 @@
|
||||
"is_alias": false,
|
||||
"metadata_source": "embedded-mcp-metadata",
|
||||
"name": "download_file",
|
||||
"parameter_count": 3,
|
||||
"parameter_count": 6,
|
||||
"parameters": {
|
||||
"no-resume": {
|
||||
"description": "关闭断点续传 (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "关闭断点续传 (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "关闭断点续传 (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "noResume"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "fallback",
|
||||
"source": "default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"node": {
|
||||
"description": "文件 ID (dentryUuid) (必填)",
|
||||
"field_provenance": {
|
||||
@@ -5520,6 +5611,202 @@
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
"parallel": {
|
||||
"default": "4",
|
||||
"description": "分片下载并发数,范围 1-8 (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载并发数,范围 1-8 (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载并发数,范围 1-8 (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "parallel"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "integer"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "integer"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "integer"
|
||||
},
|
||||
"part-size": {
|
||||
"default": "16MB",
|
||||
"description": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "partSize"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
"space-id": {
|
||||
"description": "文件所属空间 ID (可选)",
|
||||
"field_provenance": {
|
||||
@@ -5937,8 +6224,99 @@
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"is_alias": false,
|
||||
"name": "download_file_version",
|
||||
"parameter_count": 3,
|
||||
"parameter_count": 6,
|
||||
"parameters": {
|
||||
"no-resume": {
|
||||
"description": "关闭断点续传 (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "关闭断点续传 (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "关闭断点续传 (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "noResume"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "fallback",
|
||||
"source": "default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"node": {
|
||||
"description": "文件 ID (dentryUuid) 或 URL (必填)",
|
||||
"field_provenance": {
|
||||
@@ -6133,6 +6511,202 @@
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
"parallel": {
|
||||
"default": "4",
|
||||
"description": "分片下载并发数,范围 1-8 (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载并发数,范围 1-8 (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载并发数,范围 1-8 (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "parallel"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "integer"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "integer"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "integer"
|
||||
},
|
||||
"part-size": {
|
||||
"default": "16MB",
|
||||
"description": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "partSize"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"description": "历史版本号 (必填,正整数,从 drive list --versions 获取)",
|
||||
"field_provenance": {
|
||||
|
||||
@@ -1790,9 +1790,15 @@
|
||||
"drive.apply_permission --reason": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --reason is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.apply_permission --role": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --role is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.apply_permission --users": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --users is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file --no-resume": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
|
||||
"drive.download_file --output": "local output path",
|
||||
"drive.download_file --parallel": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
|
||||
"drive.download_file --part-size": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
|
||||
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --parallel": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --part-size": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --version": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.get_cover --node": "Reviewed unpinned adapter: drive.get_cover has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.get_star_list --content-types": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --content-types is a CLI wrapper input and does not publish a direct interface property.",
|
||||
|
||||
@@ -3434,6 +3434,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
conversationID := mustGetFlag(cmd, "open-conversation-id")
|
||||
messageID := mustGetFlag(cmd, "message-id")
|
||||
outputPath := mustGetFlag(cmd, "output")
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
switch resourceType {
|
||||
case "mediaId":
|
||||
@@ -3455,7 +3456,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
ctx := context.Background()
|
||||
|
||||
// Step 1: 获取下载 URL
|
||||
deps.Out.PrintInfo("[1/2] 获取资源下载链接...")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("[1/2] 获取资源下载链接...")
|
||||
}
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "im", "get_resource_download_url", map[string]any{
|
||||
"resourceType": resourceType,
|
||||
"resourceId": resourceID,
|
||||
@@ -3488,11 +3491,20 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
}
|
||||
|
||||
// Step 2: HTTP GET 下载文件
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath))
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath))
|
||||
}
|
||||
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonMode {
|
||||
return deps.Out.PrintJSONUnescaped(map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": resourceURL,
|
||||
"output": outputPath,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChatDownloadMediaJSONPreservesLegacyResult(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
previousHTTPGetFile := httpGetFile
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
httpGetFile = previousHTTPGetFile
|
||||
})
|
||||
|
||||
const downloadURL = "https://download.example.test/photo.jpg?token=one&part=two"
|
||||
caller := &scriptedToolCaller{
|
||||
format: "json",
|
||||
steps: []scriptedToolStep{{
|
||||
text: `{"resourceUrl":"` + downloadURL + `"}`,
|
||||
}},
|
||||
}
|
||||
InitDeps(caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out = &Formatter{w: &stdout, errW: io.Discard}
|
||||
|
||||
outputDir := t.TempDir()
|
||||
wantOutput := filepath.Join(outputDir, "photo.jpg")
|
||||
httpGetFile = func(_ context.Context, gotURL string, _ map[string]string, gotOutput string) error {
|
||||
if gotURL != downloadURL {
|
||||
t.Fatalf("download URL = %q, want %q", gotURL, downloadURL)
|
||||
}
|
||||
if gotOutput != wantOutput {
|
||||
t.Fatalf("download output = %q, want %q", gotOutput, wantOutput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
root := newChatCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{
|
||||
"message", "download-media",
|
||||
"--type=mediaId",
|
||||
"--resource-id=resource",
|
||||
"--message-id=message",
|
||||
"--open-conversation-id=conversation",
|
||||
"--output=" + outputDir,
|
||||
})
|
||||
if err := root.ExecuteContext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Success bool `json:"success"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !got.Success || got.DownloadURL != downloadURL || got.Output != wantOutput {
|
||||
t.Fatalf("result = %#v, want success=true downloadUrl=%q output=%q", got, downloadURL, wantOutput)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "[INFO]") {
|
||||
t.Fatalf("JSON stdout contains progress text: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "?token=one&part=two") {
|
||||
t.Fatalf("downloadUrl was escaped or changed: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -276,7 +276,8 @@ func defaultHTTPPutFile(ctx context.Context, url string, headers map[string]stri
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("OSS upload failed: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
// typed httpStatusError 供上层按 401/403 分支重取凭证
|
||||
return fmt.Errorf("OSS upload failed: %w", &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)})
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -434,7 +435,8 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
// typed httpStatusError 供上层按 401/403 分支重取凭证
|
||||
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
outFile, err := docCreateDestination(destPath)
|
||||
|
||||
+136
-14
@@ -476,7 +476,8 @@ func newDriveCommand() *cobra.Command {
|
||||
--output 指定本地保存路径,可以是文件路径或目录。
|
||||
如果指定目录,文件名从下载 URL 中自动推断。`,
|
||||
Example: ` dws drive download --node <dentryUuid> --output ./report.pdf
|
||||
dws drive download --node <dentryUuid> --output ~/downloads/`,
|
||||
dws drive download --node <dentryUuid> --output ~/downloads/
|
||||
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fileID := flagOrFallback(cmd, "node", "file-id")
|
||||
if fileID == "" {
|
||||
@@ -492,6 +493,15 @@ func newDriveCommand() *cobra.Command {
|
||||
argsMap["spaceId"] = v
|
||||
}
|
||||
|
||||
// fail-fast:分片下载参数校验
|
||||
dlOpts, err := driveDownloadOptionsFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dlOpts.logf = func(format string, a ...any) {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "下载钉盘文件")
|
||||
deps.Out.PrintKeyValue("文件ID", fileID)
|
||||
@@ -499,7 +509,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Step 1: 获取下载 URL 和签名请求头
|
||||
deps.Out.PrintInfo("[1/2] 获取下载链接...")
|
||||
@@ -508,7 +518,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
resourceURL, dlHeaders, err := parseDownloadInfo(text)
|
||||
resourceURL, dlHeaders, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -523,9 +533,34 @@ func newDriveCommand() *cobra.Command {
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
|
||||
// Step 2: HTTP GET 下载文件
|
||||
// Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试)
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
|
||||
dlOpts.knownSize = parseDownloadFileSize(text)
|
||||
dlOpts.nodeID = fileID
|
||||
dlOpts.version = parseDownloadFileVersion(text)
|
||||
fetchCred := func(fctx context.Context) (string, map[string]string, int, error) {
|
||||
t, ferr := callMCPToolReturnText(fctx, "download_file", argsMap)
|
||||
if ferr != nil {
|
||||
return "", nil, 0, ferr
|
||||
}
|
||||
u, h, perr := parseDriveDownloadInfo(t)
|
||||
if perr != nil {
|
||||
return "", nil, 0, perr
|
||||
}
|
||||
return u, h, parseDownloadFileVersion(t), nil
|
||||
}
|
||||
if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil {
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||
partSize, _ := cmd.Flags().GetString("part-size")
|
||||
noResume, _ := cmd.Flags().GetBool("no-resume")
|
||||
if partSize != "" && !noResume {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n")
|
||||
} else {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n")
|
||||
}
|
||||
cmd.SilenceErrors = true
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -564,6 +599,15 @@ func newDriveCommand() *cobra.Command {
|
||||
return fmt.Errorf("flag --output is required")
|
||||
}
|
||||
|
||||
// fail-fast:分片下载参数校验
|
||||
dlOpts, err := driveDownloadOptionsFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dlOpts.logf = func(format string, a ...any) {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
|
||||
deps.Out.PrintKeyValue("节点ID", fileID)
|
||||
@@ -572,16 +616,17 @@ func newDriveCommand() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := cmd.Context()
|
||||
deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...")
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
|
||||
dlArgsMap := map[string]any{
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
})
|
||||
}
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", dlArgsMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceURL, dlHeaders, err := parseDownloadInfo(text)
|
||||
resourceURL, dlHeaders, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -593,7 +638,32 @@ func newDriveCommand() *cobra.Command {
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
|
||||
dlOpts.knownSize = parseDownloadFileSize(text)
|
||||
dlOpts.nodeID = fileID
|
||||
dlOpts.version = versionNum
|
||||
fetchCred := func(fctx context.Context) (string, map[string]string, int, error) {
|
||||
t, ferr := callMCPToolReturnTextOnServer(fctx, "drive", "download_file_version", dlArgsMap)
|
||||
if ferr != nil {
|
||||
return "", nil, 0, ferr
|
||||
}
|
||||
u, h, perr := parseDriveDownloadInfo(t)
|
||||
if perr != nil {
|
||||
return "", nil, 0, perr
|
||||
}
|
||||
return u, h, parseDownloadFileVersion(t), nil
|
||||
}
|
||||
if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil {
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||
partSize, _ := cmd.Flags().GetString("part-size")
|
||||
noResume, _ := cmd.Flags().GetBool("no-resume")
|
||||
if partSize != "" && !noResume {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n")
|
||||
} else {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n")
|
||||
}
|
||||
cmd.SilenceErrors = true
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
|
||||
@@ -709,10 +779,16 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
|
||||
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
|
||||
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
|
||||
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
|
||||
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
|
||||
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
|
||||
|
||||
driveDownloadVersionCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
driveDownloadVersionCmd.Flags().Int("version", 0, "历史版本号 (必填,正整数,从 drive list --versions 获取)")
|
||||
driveDownloadVersionCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
|
||||
driveDownloadVersionCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
|
||||
driveDownloadVersionCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
|
||||
driveDownloadVersionCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
|
||||
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
|
||||
driveDownloadVersionCmd.Flags().String(alias, "", "")
|
||||
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
|
||||
@@ -2006,13 +2082,13 @@ func uploadToDrive(ctx context.Context, filePath, fileName string, fileSize int6
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceURL, uploadID, headers, err := parseDriveUploadInfo(text)
|
||||
// HTTP PUT 上传文件(OSS 与中心协议同路径;headers 透传,401/403 重取凭证重试一次)
|
||||
uploadID, err := driveUploadPut(ctx, text, func(rctx context.Context) (string, error) {
|
||||
return callMCPToolReturnTextOnServer(rctx, "drive", "get_upload_info", step1Args)
|
||||
}, filePath, fileSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commitArgs := map[string]any{
|
||||
"fileName": fileName,
|
||||
@@ -2219,3 +2295,49 @@ func isPermissionCLIError(err error) bool {
|
||||
var patErr *PATError
|
||||
return errors.As(err, &patErr)
|
||||
}
|
||||
|
||||
// parseDriveDownloadInfo 从 drive 的 download_file 返回里取下载 URL 与请求头。
|
||||
// drive 返回形如 {"result":{"downloadUrl":"https://..."}};OSS 预签名 URL 自带签名参数、
|
||||
// 无额外请求头;中心协议(httpToCenterWithToken)返回的 headers 含 dentry-token,
|
||||
// 需原样透传。对历史字段名(resourceUrl / resourceUrls[].url)做 fallback。
|
||||
func parseDriveDownloadInfo(text string) (string, map[string]string, error) {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return "", nil, fmt.Errorf("解析 download_file 返回失败: %w", err)
|
||||
}
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
if h, ok := data["headers"].(map[string]any); ok {
|
||||
for k, v := range h {
|
||||
if s, ok := v.(string); ok {
|
||||
headers[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dlURL, _ := data["downloadUrl"].(string)
|
||||
if dlURL == "" {
|
||||
dlURL, _ = data["resourceUrl"].(string)
|
||||
}
|
||||
if dlURL == "" {
|
||||
if arr, ok := data["resourceUrls"].([]any); ok && len(arr) > 0 {
|
||||
if first, ok := arr[0].(map[string]any); ok {
|
||||
dlURL, _ = first["url"].(string)
|
||||
if h, ok := first["headers"].(map[string]any); ok {
|
||||
for k, v := range h {
|
||||
if s, ok := v.(string); ok {
|
||||
headers[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if dlURL == "" {
|
||||
return "", nil, fmt.Errorf("download_file 未返回下载链接(downloadUrl 为空)")
|
||||
}
|
||||
return dlURL, headers, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,847 @@
|
||||
package helpers
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// drive 传输增强:中心协议(token 化凭证)+ 分片下载(Range)
|
||||
//
|
||||
// 下载:文件大小 ≥ 2×part-size 时自动分片并发下载(对齐 aws s3 cp /
|
||||
// ossutil 惯例),断点续传默认开启(<dest>.dwspart 临时文件 + checkpoint
|
||||
// 元信息),服务端不支持 Range 时自动回退整流;401/403(凭证过期)自动
|
||||
// 重新调用 MCP 取新凭证后续传。
|
||||
// 上传:中心协议(uploadType=httpToCenterWithToken)与 OSS 走同一 PUT
|
||||
// 路径(URL 服务端拼好、headers 透传、客户端零追加),401/403 重取凭证
|
||||
// 重试一次;服务端超限错误补充可读提示。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
driveDownloadDefaultPartSize = 16 * 1024 * 1024 // --part-size 默认 16MB
|
||||
driveDownloadMinPartSize = 1 * 1024 * 1024
|
||||
driveDownloadMaxPartSize = 1024 * 1024 * 1024
|
||||
driveDownloadDefaultParallel = 4
|
||||
driveDownloadMaxParallel = 8
|
||||
// 单分片常规失败指数退避重试次数(不含 401/403 凭证刷新)。
|
||||
driveDownloadPartRetries = 3
|
||||
// 单分片 401/403 凭证刷新重试上限(防止无效凭证死循环)。
|
||||
driveDownloadPartAuthRetries = 2
|
||||
|
||||
drivePartFileSuffix = ".dwspart"
|
||||
drivePartMetaSuffix = ".dwspart.meta"
|
||||
driveCheckpointVersion = 1
|
||||
uploadTypeCenterToken = "httpToCenterWithToken"
|
||||
driveTransferBodyErrCap = 2048 // 错误响应 body 截断长度
|
||||
)
|
||||
|
||||
// driveRangeClient 分片下载/探测专用 HTTP 客户端。
|
||||
// 整流路径仍走可注入的 httpGetFile,保持既有测试注入点不变。
|
||||
var driveRangeClient = &http.Client{Timeout: 10 * time.Minute}
|
||||
|
||||
// errCredentialRefreshVersionUnknown 凭证刷新后无法验证文件版本一致性(双方之一
|
||||
// version=0),激进策略要求清空已完成分片从头下载。
|
||||
var errCredentialRefreshVersionUnknown = errors.New("凭证刷新后无法验证文件版本一致性,需从头下载")
|
||||
|
||||
// Testable OS operation hooks (package-level for coverage injection).
|
||||
var (
|
||||
driveJsonMarshal = json.Marshal
|
||||
driveOsRename = os.Rename
|
||||
driveFileTruncate = (*os.File).Truncate
|
||||
driveFileSync = (*os.File).Sync
|
||||
driveFileStat = (*os.File).Stat
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// HTTP 状态错误
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// httpStatusError 表示非 2xx 的 HTTP 响应,供上层按状态码分支(401/403 重取凭证等)。
|
||||
type httpStatusError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *httpStatusError) Error() string {
|
||||
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// isAuthStatusError 判断错误是否为 401/403(凭证过期/无效)。
|
||||
// 兼容 typed httpStatusError 与文本形态(测试注入或历史包装的 "HTTP 401/403" 错误)。
|
||||
func isAuthStatusError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var se *httpStatusError
|
||||
if errors.As(err, &se) {
|
||||
return se.StatusCode == http.StatusUnauthorized || se.StatusCode == http.StatusForbidden
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403")
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 参数解析
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// parsePartSize 解析 --part-size 的人类可读值(16MB、512KB、1GB;纯数字按字节)。
|
||||
func parsePartSize(s string) (int64, error) {
|
||||
v := strings.TrimSpace(strings.ToUpper(s))
|
||||
if v == "" {
|
||||
return 0, fmt.Errorf("--part-size 不能为空(示例: 16MB、512KB、1GB)")
|
||||
}
|
||||
unit := int64(1)
|
||||
switch {
|
||||
case strings.HasSuffix(v, "GB"):
|
||||
unit, v = 1<<30, strings.TrimSuffix(v, "GB")
|
||||
case strings.HasSuffix(v, "MB"):
|
||||
unit, v = 1<<20, strings.TrimSuffix(v, "MB")
|
||||
case strings.HasSuffix(v, "KB"):
|
||||
unit, v = 1<<10, strings.TrimSuffix(v, "KB")
|
||||
case strings.HasSuffix(v, "G"):
|
||||
unit, v = 1<<30, strings.TrimSuffix(v, "G")
|
||||
case strings.HasSuffix(v, "M"):
|
||||
unit, v = 1<<20, strings.TrimSuffix(v, "M")
|
||||
case strings.HasSuffix(v, "K"):
|
||||
unit, v = 1<<10, strings.TrimSuffix(v, "K")
|
||||
case strings.HasSuffix(v, "B"):
|
||||
v = strings.TrimSuffix(v, "B")
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("--part-size 格式非法: %q(示例: 16MB、512KB、1GB)", s)
|
||||
}
|
||||
size := n * unit
|
||||
if size < driveDownloadMinPartSize || size > driveDownloadMaxPartSize {
|
||||
return 0, fmt.Errorf("--part-size 取值范围 %s - %s,当前值: %s",
|
||||
formatByteSize(driveDownloadMinPartSize), formatByteSize(driveDownloadMaxPartSize), s)
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func formatByteSize(n int64) string {
|
||||
switch {
|
||||
case n >= 1<<30 && n%(1<<30) == 0:
|
||||
return fmt.Sprintf("%dGB", n/(1<<30))
|
||||
case n >= 1<<20 && n%(1<<20) == 0:
|
||||
return fmt.Sprintf("%dMB", n/(1<<20))
|
||||
case n >= 1<<10 && n%(1<<10) == 0:
|
||||
return fmt.Sprintf("%dKB", n/(1<<10))
|
||||
default:
|
||||
return fmt.Sprintf("%dB", n)
|
||||
}
|
||||
}
|
||||
|
||||
// driveDownloadOptions 分片下载选项(由 drive download 的 flag 解析而来)。
|
||||
type driveDownloadOptions struct {
|
||||
partSize int64
|
||||
parallel int
|
||||
resume bool
|
||||
knownSize int64 // MCP 返回的 fileSize;未知(0)或小于阈值时直接整流
|
||||
nodeID string // 节点唯一标识(dentryUuid),用于生成 checkpoint 指纹
|
||||
version int // 文件版本号;0 表示最新版
|
||||
logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
// driveDownloadOptionsFromFlags 解析并校验 --part-size / --parallel / --no-resume。
|
||||
func driveDownloadOptionsFromFlags(cmd *cobra.Command) (driveDownloadOptions, error) {
|
||||
raw, _ := cmd.Flags().GetString("part-size")
|
||||
partSize, err := parsePartSize(raw)
|
||||
if err != nil {
|
||||
return driveDownloadOptions{}, err
|
||||
}
|
||||
parallel, _ := cmd.Flags().GetInt("parallel")
|
||||
if parallel < 1 || parallel > driveDownloadMaxParallel {
|
||||
return driveDownloadOptions{}, fmt.Errorf("--parallel 取值范围 1-%d,当前值: %d", driveDownloadMaxParallel, parallel)
|
||||
}
|
||||
noResume, _ := cmd.Flags().GetBool("no-resume")
|
||||
return driveDownloadOptions{partSize: partSize, parallel: parallel, resume: !noResume}, nil
|
||||
}
|
||||
|
||||
// parseDownloadFileSize 从 download_file 返回中提取 fileSize(缺失/非法返回 0)。
|
||||
func parseDownloadFileSize(text string) int64 {
|
||||
var data map[string]any
|
||||
if json.Unmarshal([]byte(text), &data) != nil {
|
||||
return 0
|
||||
}
|
||||
if r, ok := data["result"].(map[string]any); ok {
|
||||
data = r
|
||||
}
|
||||
switch v := data["fileSize"].(type) {
|
||||
case float64:
|
||||
return int64(v)
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(v, 10, 64)
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseDownloadFileVersion 从 MCP download_file 响应中提取文件当前版本号。
|
||||
// 返回 0 表示未获取到(兼容旧版 MCP 不返回 version 的场景)。
|
||||
func parseDownloadFileVersion(text string) int {
|
||||
var data map[string]any
|
||||
if json.Unmarshal([]byte(text), &data) != nil {
|
||||
return 0
|
||||
}
|
||||
if r, ok := data["result"].(map[string]any); ok {
|
||||
data = r
|
||||
}
|
||||
switch v := data["version"].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(v)
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 凭证状态(分片过程共享,401/403 时 single-flight 刷新)
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// driveCredentialFetcher 重新调用 MCP 获取下载 URL + headers(含 dentry-token)。
|
||||
type driveCredentialFetcher func(ctx context.Context) (url string, headers map[string]string, version int, err error)
|
||||
|
||||
type driveCredentialState struct {
|
||||
mu sync.Mutex
|
||||
fetch driveCredentialFetcher
|
||||
url string
|
||||
headers map[string]string
|
||||
gen int
|
||||
initialVersion int // 首次获取的文件版本号;0 表示未知(兼容旧 MCP)
|
||||
}
|
||||
|
||||
func (cs *driveCredentialState) current() (string, map[string]string, int) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return cs.url, cs.headers, cs.gen
|
||||
}
|
||||
|
||||
// refresh 重取凭证。仅当调用方持有的 generation 仍是最新时才真正重取
|
||||
// (其他并发分片已刷新过则直接复用新凭证,避免重复 MCP 调用)。
|
||||
func (cs *driveCredentialState) refresh(ctx context.Context, gen int) error {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
if cs.gen > gen {
|
||||
return nil // 已被其他分片刷新
|
||||
}
|
||||
if cs.fetch == nil {
|
||||
return fmt.Errorf("下载凭证已过期且无法自动刷新")
|
||||
}
|
||||
url, headers, version, err := cs.fetch(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 版本校验
|
||||
if cs.initialVersion > 0 && version > 0 {
|
||||
if version != cs.initialVersion {
|
||||
// 双方版本已知且不同 → 文件已被覆盖,终止下载防止数据不一致
|
||||
return fmt.Errorf("下载凭证刷新后文件版本已变更(%d → %d),终止下载以防数据不一致", cs.initialVersion, version)
|
||||
}
|
||||
// 版本一致,正常继续
|
||||
} else {
|
||||
// 激进策略:版本不可验证(至少一方为 0),更新凭证但返回特殊错误
|
||||
// 让上层清空已完成分片从头下载
|
||||
cs.url, cs.headers = url, headers
|
||||
cs.gen++
|
||||
return errCredentialRefreshVersionUnknown
|
||||
}
|
||||
cs.url, cs.headers = url, headers
|
||||
cs.gen++
|
||||
return nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 下载入口:整流 / 分片自动分派
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// driveTransferDownload 下载入口:按文件大小自动选择整流或分片下载。
|
||||
// - 文件大小未知(MCP 未返回 fileSize)或 < 2×partSize → 直接整流
|
||||
// (复用可注入的 httpGetFile,保持存量行为与测试注入边界不变,
|
||||
// 401/403 时重取凭证重试一次);
|
||||
// - 已知大小 ≥ 2×partSize → 首请求以 Range: bytes=0-0 探测:206 且解析出
|
||||
// 总长 → 分片下载;服务端返回 200(不支持 Range)→ 直接消费该响应整流落盘。
|
||||
func driveTransferDownload(ctx context.Context, fetch driveCredentialFetcher, rawURL string, headers map[string]string, destPath string, opts driveDownloadOptions) error {
|
||||
if opts.partSize <= 0 {
|
||||
opts.partSize = driveDownloadDefaultPartSize
|
||||
}
|
||||
if opts.parallel <= 0 {
|
||||
opts.parallel = driveDownloadDefaultParallel
|
||||
}
|
||||
threshold := 2 * opts.partSize
|
||||
if opts.knownSize < threshold {
|
||||
// 含 knownSize==0(大小未知):不发起额外探测请求,保持存量整流行为
|
||||
return downloadSingleWithAuthRetry(ctx, fetch, rawURL, headers, destPath)
|
||||
}
|
||||
|
||||
creds := &driveCredentialState{fetch: fetch, url: rawURL, headers: headers, initialVersion: opts.version}
|
||||
totalSize, fullResp, err := probeRangeSupport(ctx, creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fullResp != nil {
|
||||
// 服务端不支持 Range(忽略探测请求头返回 200 全量流):直接消费落盘
|
||||
defer fullResp.Body.Close()
|
||||
return writeStreamToFile(fullResp.Body, destPath)
|
||||
}
|
||||
curURL, curHeaders, _ := creds.current()
|
||||
if totalSize <= 0 || totalSize < threshold {
|
||||
// 总长未知(Content-Range 异常)或小于阈值:整流下载
|
||||
return downloadSingleWithAuthRetry(ctx, func(fctx context.Context) (string, map[string]string, int, error) {
|
||||
if fetch == nil {
|
||||
return "", nil, 0, fmt.Errorf("下载凭证已过期且无法自动刷新")
|
||||
}
|
||||
return fetch(fctx)
|
||||
}, curURL, curHeaders, destPath)
|
||||
}
|
||||
return downloadRangedParts(ctx, creds, destPath, totalSize, opts)
|
||||
}
|
||||
|
||||
// downloadSingleWithAuthRetry 整流下载;401/403(凭证过期)时重新调用 MCP
|
||||
// 获取新 URL+token 重试一次,仍失败走既有错误路径。
|
||||
func downloadSingleWithAuthRetry(ctx context.Context, fetch driveCredentialFetcher, urlStr string, headers map[string]string, destPath string) error {
|
||||
err := httpGetFile(ctx, urlStr, headers, destPath)
|
||||
if err == nil || !isAuthStatusError(err) || fetch == nil {
|
||||
return err
|
||||
}
|
||||
newURL, newHeaders, _, ferr := fetch(ctx)
|
||||
if ferr != nil {
|
||||
return err
|
||||
}
|
||||
return httpGetFile(ctx, newURL, newHeaders, destPath)
|
||||
}
|
||||
|
||||
// probeRangeSupport 发送 Range: bytes=0-0 探测请求验证 206/Content-Range。
|
||||
// 返回 (totalSize, nil, nil) 表示支持 Range 且已知总长;
|
||||
// 返回 (0, resp, nil) 表示服务端忽略 Range 返回 200 全量响应(调用方直接消费);
|
||||
// 401/403 时刷新凭证重试一次。
|
||||
func probeRangeSupport(ctx context.Context, creds *driveCredentialState) (int64, *http.Response, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
urlStr, headers, gen := creds.current()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=0-0")
|
||||
resp, err := driveRangeClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusPartialContent:
|
||||
total, perr := parseContentRangeTotal(resp.Header.Get("Content-Range"))
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if perr != nil {
|
||||
return 0, nil, nil // Content-Range 异常:总长未知,回退整流
|
||||
}
|
||||
return total, nil, nil
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
return 0, resp, nil
|
||||
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
|
||||
resp.Body.Close()
|
||||
if attempt > 0 {
|
||||
return 0, nil, fmt.Errorf("下载凭证刷新后仍鉴权失败")
|
||||
}
|
||||
if rerr := creds.refresh(ctx, gen); rerr != nil && !errors.Is(rerr, errCredentialRefreshVersionUnknown) {
|
||||
return 0, nil, fmt.Errorf("重新获取下载凭证失败: %w (原错误: %v)",
|
||||
rerr, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)})
|
||||
}
|
||||
// errCredentialRefreshVersionUnknown 在探测阶段无需处理(尚无已完成分片),
|
||||
// 凭证已更新,循环继续用新凭证重试探测。
|
||||
default:
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
|
||||
resp.Body.Close()
|
||||
return 0, nil, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseContentRangeTotal 从 "bytes 0-0/12345" 解析总长;"*" 视为未知。
|
||||
func parseContentRangeTotal(cr string) (int64, error) {
|
||||
idx := strings.LastIndex(cr, "/")
|
||||
if idx < 0 || idx == len(cr)-1 {
|
||||
return 0, fmt.Errorf("非法 Content-Range: %q", cr)
|
||||
}
|
||||
totalStr := cr[idx+1:]
|
||||
if totalStr == "*" {
|
||||
return 0, fmt.Errorf("Content-Range 总长未知: %q", cr)
|
||||
}
|
||||
total, err := strconv.ParseInt(totalStr, 10, 64)
|
||||
if err != nil || total <= 0 {
|
||||
return 0, fmt.Errorf("非法 Content-Range 总长: %q", cr)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// parseContentRange 解析 "bytes <start>-<end>/<total>" 格式的 Content-Range 头。
|
||||
// 返回值 start、end 为字节偏移(闭区间),total 为文件总长("*" 视为 -1)。
|
||||
func parseContentRange(header string) (start, end, total int64, err error) {
|
||||
if header == "" {
|
||||
return 0, 0, 0, fmt.Errorf("Content-Range 为空")
|
||||
}
|
||||
// 去掉 "bytes " 前缀
|
||||
const prefix = "bytes "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range 前缀: %q", header)
|
||||
}
|
||||
rest := header[len(prefix):] // e.g. "0-1048575/104857600"
|
||||
// 按 "/" 分割范围与总长
|
||||
slashIdx := strings.LastIndex(rest, "/")
|
||||
if slashIdx < 0 || slashIdx == len(rest)-1 {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range 格式: %q", header)
|
||||
}
|
||||
rangePart := rest[:slashIdx] // "0-1048575"
|
||||
totalPart := rest[slashIdx+1:] // "104857600" 或 "*"
|
||||
// 解析 total
|
||||
if totalPart == "*" {
|
||||
total = -1
|
||||
} else {
|
||||
total, err = strconv.ParseInt(totalPart, 10, 64)
|
||||
if err != nil || total <= 0 {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range 总长: %q", header)
|
||||
}
|
||||
}
|
||||
// 按 "-" 分割 start 和 end
|
||||
dashIdx := strings.Index(rangePart, "-")
|
||||
if dashIdx < 0 {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range 区间: %q", header)
|
||||
}
|
||||
start, err = strconv.ParseInt(rangePart[:dashIdx], 10, 64)
|
||||
if err != nil || start < 0 {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range start: %q", header)
|
||||
}
|
||||
end, err = strconv.ParseInt(rangePart[dashIdx+1:], 10, 64)
|
||||
if err != nil || end < start {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range end: %q", header)
|
||||
}
|
||||
return start, end, total, nil
|
||||
}
|
||||
|
||||
func writeStreamToFile(r io.Reader, destPath string) error {
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, r); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 分片切分与 checkpoint
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
type driveDownloadPart struct {
|
||||
index int
|
||||
offset int64
|
||||
length int64
|
||||
}
|
||||
|
||||
// splitDownloadParts 按 partSize 等长切片,末片为余量。
|
||||
func splitDownloadParts(totalSize, partSize int64) []driveDownloadPart {
|
||||
if totalSize <= 0 || partSize <= 0 {
|
||||
return nil
|
||||
}
|
||||
count := int((totalSize + partSize - 1) / partSize)
|
||||
parts := make([]driveDownloadPart, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
offset := int64(i) * partSize
|
||||
length := partSize
|
||||
if offset+length > totalSize {
|
||||
length = totalSize - offset
|
||||
}
|
||||
parts = append(parts, driveDownloadPart{index: i, offset: offset, length: length})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// driveDownloadCheckpoint 断点续传元信息,随每个分片完成原子落盘。
|
||||
type driveDownloadCheckpoint struct {
|
||||
Version int `json:"version"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
PartSize int64 `json:"partSize"`
|
||||
Completed []bool `json:"completed"`
|
||||
}
|
||||
|
||||
// driveDownloadFingerprint 基于节点 ID + 版本号 + 文件总长 + 资源 URL 计算指纹。
|
||||
// version>0 时只取 URL path(重签名不影响 checkpoint 复用);version==0(最新版)时
|
||||
// 取完整 path+query——中心协议相同 path 可能对应不同实际版本,query 中的签名/token
|
||||
// 标识了具体资源快照,防止错误复用旧 checkpoint。
|
||||
// resourceURL 为空时不影响其他字段的指纹计算(安全降级)。
|
||||
func driveDownloadFingerprint(nodeID string, version int, totalSize int64, resourceURL string) string {
|
||||
urlComponent := ""
|
||||
if resourceURL != "" {
|
||||
if u, err := url.Parse(resourceURL); err == nil && u != nil {
|
||||
if version == 0 {
|
||||
// 最新版:含 query 以区分不同签名(不同实际版本)
|
||||
urlComponent = u.RequestURI()
|
||||
} else {
|
||||
// 指定版本:只取 path,重签名不应废弃 checkpoint
|
||||
urlComponent = u.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%d|%d|%s", nodeID, version, totalSize, urlComponent)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// loadDriveDownloadCheckpoint 读取并校验 checkpoint;任一字段不匹配返回 nil(从头下载)。
|
||||
func loadDriveDownloadCheckpoint(metaPath, fingerprint string, totalSize, partSize int64, partCount int) *driveDownloadCheckpoint {
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cp driveDownloadCheckpoint
|
||||
if err := json.Unmarshal(data, &cp); err != nil {
|
||||
return nil
|
||||
}
|
||||
if cp.Version != driveCheckpointVersion || cp.Fingerprint != fingerprint ||
|
||||
cp.TotalSize != totalSize || cp.PartSize != partSize || len(cp.Completed) != partCount {
|
||||
return nil
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
// save 原子写入(临时文件 + rename),避免中断产生半截 checkpoint。
|
||||
func (cp *driveDownloadCheckpoint) save(metaPath string) error {
|
||||
data, err := driveJsonMarshal(cp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := metaPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return driveOsRename(tmp, metaPath)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 分片下载引擎
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// downloadRangedParts 并发分片下载到 <dest>.dwspart,全部完成后校验总长并
|
||||
// 原子重命名为 destPath、清理 checkpoint;中途失败保留分片产物供断点续传。
|
||||
func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destPath string, totalSize int64, opts driveDownloadOptions) error {
|
||||
parts := splitDownloadParts(totalSize, opts.partSize)
|
||||
partPath := destPath + drivePartFileSuffix
|
||||
metaPath := destPath + drivePartMetaSuffix
|
||||
// 取首次凭证 URL 用于指纹计算,防止同大小文件覆盖后 checkpoint 错误复用
|
||||
initialURL, _, _ := creds.current()
|
||||
fingerprint := driveDownloadFingerprint(opts.nodeID, opts.version, totalSize, initialURL)
|
||||
|
||||
var cp *driveDownloadCheckpoint
|
||||
if opts.resume {
|
||||
cp = loadDriveDownloadCheckpoint(metaPath, fingerprint, totalSize, opts.partSize, len(parts))
|
||||
// 分片数据文件缺失或长度不符时 checkpoint 作废,从头下载
|
||||
if cp != nil {
|
||||
if fi, err := os.Stat(partPath); err != nil || fi.Size() != totalSize {
|
||||
cp = nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// --no-resume:清理历史断点产物,从头下载且不写 checkpoint
|
||||
_ = os.Remove(partPath)
|
||||
_ = os.Remove(metaPath)
|
||||
}
|
||||
if cp == nil {
|
||||
cp = &driveDownloadCheckpoint{
|
||||
Version: driveCheckpointVersion,
|
||||
Fingerprint: fingerprint,
|
||||
TotalSize: totalSize,
|
||||
PartSize: opts.partSize,
|
||||
Completed: make([]bool, len(parts)),
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(partPath, os.O_RDWR|os.O_CREATE, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建分片临时文件失败: %w", err)
|
||||
}
|
||||
if err := driveFileTruncate(f, totalSize); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("预分配分片临时文件失败: %w", err)
|
||||
}
|
||||
|
||||
remaining := 0
|
||||
for _, p := range parts {
|
||||
if !cp.Completed[p.index] {
|
||||
remaining++
|
||||
}
|
||||
}
|
||||
if opts.logf != nil {
|
||||
if remaining < len(parts) {
|
||||
opts.logf("断点续传: 共 %d 分片(%s/片,并发 %d),已完成 %d,续传 %d",
|
||||
len(parts), formatByteSize(opts.partSize), opts.parallel, len(parts)-remaining, remaining)
|
||||
} else {
|
||||
opts.logf("分片下载: 共 %d 分片(%s/片,并发 %d)", len(parts), formatByteSize(opts.partSize), opts.parallel)
|
||||
}
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
var (
|
||||
mu sync.Mutex // 保护 cp 与 checkpoint 落盘
|
||||
wg sync.WaitGroup
|
||||
errOnce sync.Once
|
||||
firstErr error
|
||||
)
|
||||
fail := func(err error) {
|
||||
errOnce.Do(func() {
|
||||
firstErr = err
|
||||
cancel()
|
||||
})
|
||||
}
|
||||
|
||||
jobs := make(chan driveDownloadPart)
|
||||
workers := opts.parallel
|
||||
if workers > remaining {
|
||||
workers = remaining
|
||||
}
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for part := range jobs {
|
||||
if runCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
|
||||
fail(fmt.Errorf("分片 %d/%d 下载失败: %w", part.index+1, len(parts), err))
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
cp.Completed[part.index] = true
|
||||
var saveErr error
|
||||
if opts.resume {
|
||||
saveErr = cp.save(metaPath)
|
||||
}
|
||||
mu.Unlock()
|
||||
if saveErr != nil {
|
||||
fail(fmt.Errorf("写入下载断点信息失败: %w", saveErr))
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
dispatch:
|
||||
for _, part := range parts {
|
||||
if cp.Completed[part.index] {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case jobs <- part:
|
||||
case <-runCtx.Done():
|
||||
break dispatch
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
if firstErr != nil {
|
||||
f.Close()
|
||||
if errors.Is(firstErr, errCredentialRefreshVersionUnknown) {
|
||||
// 激进策略:版本不可验证,清空 checkpoint 和分片文件防止错误续传;
|
||||
// 用户重跑时将自然从头下载。
|
||||
_ = os.Remove(metaPath)
|
||||
_ = os.Remove(partPath)
|
||||
}
|
||||
return firstErr // 其他错误保留 .dwspart 与 checkpoint,重跑同一命令自动续传
|
||||
}
|
||||
if err := driveFileSync(f); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
fi, statErr := driveFileStat(f)
|
||||
f.Close()
|
||||
if statErr != nil {
|
||||
return statErr
|
||||
}
|
||||
if fi.Size() != totalSize {
|
||||
return fmt.Errorf("下载完成但文件长度不符: got %d, want %d", fi.Size(), totalSize)
|
||||
}
|
||||
if err := driveOsRename(partPath, destPath); err != nil {
|
||||
return fmt.Errorf("重命名下载文件失败: %w", err)
|
||||
}
|
||||
_ = os.Remove(metaPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadOnePart 下载单个分片:常规失败指数退避重试 driveDownloadPartRetries 次;
|
||||
// 401/403 触发凭证 single-flight 刷新(不计入常规重试,上限 driveDownloadPartAuthRetries),
|
||||
// 刷新后用新凭证续传,不重下其他已完成分片。
|
||||
func downloadOnePart(ctx context.Context, creds *driveCredentialState, f *os.File, part driveDownloadPart, totalSize int64) error {
|
||||
attempt := 0
|
||||
authRetries := 0
|
||||
backoff := 500 * time.Millisecond
|
||||
for {
|
||||
urlStr, headers, gen := creds.current()
|
||||
err := fetchRangeInto(ctx, urlStr, headers, f, part, totalSize)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
if isAuthStatusError(err) && authRetries < driveDownloadPartAuthRetries {
|
||||
authRetries++
|
||||
if rerr := creds.refresh(ctx, gen); rerr != nil {
|
||||
return fmt.Errorf("重新获取下载凭证失败: %w (原错误: %v)", rerr, err)
|
||||
}
|
||||
continue // 凭证刷新不计常规重试、不退避
|
||||
}
|
||||
attempt++
|
||||
if attempt > driveDownloadPartRetries {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return err
|
||||
}
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
|
||||
// fetchRangeInto 拉取 [offset, offset+length) 区间并写入文件对应偏移。
|
||||
func fetchRangeInto(ctx context.Context, urlStr string, headers map[string]string, f *os.File, part driveDownloadPart, expectedTotal int64) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", part.offset, part.offset+part.length-1))
|
||||
resp, err := driveRangeClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
|
||||
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
// 校验 Content-Range 响应区间与请求分片一致,防止代理/服务端返回错位数据
|
||||
cr := resp.Header.Get("Content-Range")
|
||||
if cr == "" {
|
||||
return fmt.Errorf("分片响应缺少 Content-Range 头,无法验证数据偏移一致性")
|
||||
}
|
||||
{
|
||||
crStart, crEnd, crTotal, crErr := parseContentRange(cr)
|
||||
if crErr != nil {
|
||||
return fmt.Errorf("Content-Range 解析失败: %w", crErr)
|
||||
}
|
||||
wantStart := part.offset
|
||||
wantEnd := part.offset + part.length - 1
|
||||
if crStart != wantStart || crEnd != wantEnd {
|
||||
return fmt.Errorf("Content-Range 区间不匹配: 响应 %d-%d, 期望 %d-%d",
|
||||
crStart, crEnd, wantStart, wantEnd)
|
||||
}
|
||||
if crTotal > 0 && expectedTotal > 0 && crTotal != expectedTotal {
|
||||
return fmt.Errorf("Content-Range 总长不匹配: 响应 %d, 期望 %d", crTotal, expectedTotal)
|
||||
}
|
||||
}
|
||||
n, err := io.Copy(io.NewOffsetWriter(f, part.offset), io.LimitReader(resp.Body, part.length))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != part.length {
|
||||
return fmt.Errorf("分片长度不符: got %d, want %d", n, part.length)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 上传:中心协议识别 + 401/403 重试 + 超限可读提示
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// parseDriveUploadType 提取 get_upload_info 返回中的 uploadType(可选字段)。
|
||||
// "httpToCenterWithToken" 表示中心协议;存量 OSS 返回无该字段,返回空串。
|
||||
func parseDriveUploadType(text string) string {
|
||||
var data map[string]any
|
||||
if json.Unmarshal([]byte(text), &data) != nil {
|
||||
return ""
|
||||
}
|
||||
if r, ok := data["result"].(map[string]any); ok {
|
||||
data = r
|
||||
}
|
||||
t, _ := data["uploadType"].(string)
|
||||
return t
|
||||
}
|
||||
|
||||
// decorateUploadSizeError 为服务端上传超限错误补充可读提示。
|
||||
// 不做本地文件大小上限校验(上限为服务端动态权益值,本地硬编码会漂移)。
|
||||
func decorateUploadSizeError(err error, uploadType string) error {
|
||||
var se *httpStatusError
|
||||
if !errors.As(err, &se) {
|
||||
return err
|
||||
}
|
||||
if se.StatusCode == http.StatusRequestEntityTooLarge ||
|
||||
(uploadType == uploadTypeCenterToken && likelySizeLimitBody(se.Body)) {
|
||||
return fmt.Errorf("%w\n提示: 文件大小可能超出空间容量或上传上限,请确认文件大小、清理空间或联系管理员调整容量后重试", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func likelySizeLimitBody(body string) bool {
|
||||
b := strings.ToLower(body)
|
||||
if strings.Contains(b, "超限") || strings.Contains(b, "超出") || strings.Contains(b, "容量") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(b, "size") && (strings.Contains(b, "limit") || strings.Contains(b, "exceed") || strings.Contains(b, "over"))
|
||||
}
|
||||
|
||||
// driveUploadPut 解析上传凭证并执行 HTTP PUT;401/403(token 过期)时通过 refetch
|
||||
// 重新获取凭证重试一次。返回最终生效凭证的 uploadId(凭证刷新后以新值为准)。
|
||||
// 中心协议(uploadType=httpToCenterWithToken)与 OSS 走同一路径:resourceUrl 为
|
||||
// 服务端拼好的完整 PUT URL(客户端零追加),headers(含 dentry-token)原样透传。
|
||||
func driveUploadPut(ctx context.Context, credText string, refetch func(context.Context) (string, error), filePath string, fileSize int64) (string, error) {
|
||||
resourceURL, uploadID, headers, err := parseDriveUploadInfo(credText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
uploadType := parseDriveUploadType(credText)
|
||||
putErr := httpPutFile(ctx, resourceURL, headers, filePath, fileSize)
|
||||
if putErr != nil && isAuthStatusError(putErr) && refetch != nil {
|
||||
text2, rerr := refetch(ctx)
|
||||
if rerr == nil {
|
||||
if url2, id2, headers2, perr := parseDriveUploadInfo(text2); perr == nil {
|
||||
uploadID = id2
|
||||
uploadType = parseDriveUploadType(text2)
|
||||
putErr = httpPutFile(ctx, url2, headers2, filePath, fileSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
if putErr != nil {
|
||||
return "", decorateUploadSizeError(putErr, uploadType)
|
||||
}
|
||||
return uploadID, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -202,14 +202,23 @@ Usage:
|
||||
Example:
|
||||
dws drive download --node <dentryUuid> --output ./report.pdf
|
||||
dws drive download --node <dentryUuid> --output ~/downloads/
|
||||
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8
|
||||
Flags:
|
||||
--node string 文件 ID (dentryUuid) (必填)
|
||||
--output string 本地保存路径 (必填),可以是文件路径或目录;如果指定目录,文件名从下载 URL 中自动推断
|
||||
--space-id string 文件所属空间 ID (可选)
|
||||
--part-size string 分片下载的分片大小,支持 KB/MB/GB 单位,范围 1MB-1GB (默认 16MB)
|
||||
--parallel int 分片下载并发数,范围 1-8 (默认 4)
|
||||
--no-resume 关闭断点续传,忽略历史下载进度从头下载 (默认开启续传)
|
||||
```
|
||||
|
||||
> **注意**:`--output` 是必填参数,不传会报错。
|
||||
|
||||
> **大文件分片下载**:
|
||||
> - 大文件自动分片并发下载,小文件整流下载,行为对用户透明,无需任何额外操作。
|
||||
> - 断点续传默认开启:下载中断后重跑同一命令会自动跳过已完成部分继续下载(`<目标文件>.dwspart` 为临时进度文件,下载完成后自动清理);不需要续传时加 `--no-resume`。
|
||||
> - 下载凭证过期会自动刷新并继续下载,已完成的部分不会重下;单个分片失败会自动重试,无需手动处理。
|
||||
|
||||
### 创建文件夹
|
||||
|
||||
```
|
||||
|
||||
@@ -170,14 +170,23 @@ Usage:
|
||||
Example:
|
||||
dws drive download --node <dentryUuid> --output ./report.pdf
|
||||
dws drive download --node <dentryUuid> --output ~/downloads/
|
||||
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8
|
||||
Flags:
|
||||
--node string 文件 ID (dentryUuid) (必填)
|
||||
--output string 本地保存路径 (文件路径或目录,不传则保存到当前目录);如果指定目录,文件名从下载 URL 中自动推断 (可选)
|
||||
--space-id string 文件所属空间 ID (可选)
|
||||
--part-size string 分片下载的分片大小,支持 KB/MB/GB 单位,范围 1MB-1GB (默认 16MB)
|
||||
--parallel int 分片下载并发数,范围 1-8 (默认 4)
|
||||
--no-resume 关闭断点续传,忽略历史下载进度从头下载 (默认开启续传)
|
||||
```
|
||||
|
||||
> **提示**:`--output` 为可选参数,不传则保存到当前目录,文件名从下载 URL 中自动推断。
|
||||
|
||||
> **大文件分片下载**:
|
||||
> - 大文件自动分片并发下载,小文件整流下载,行为对用户透明,无需任何额外操作。
|
||||
> - 断点续传默认开启:下载中断后重跑同一命令会自动跳过已完成部分继续下载(`<目标文件>.dwspart` 为临时进度文件,下载完成后自动清理);不需要续传时加 `--no-resume`。
|
||||
> - 下载凭证过期会自动刷新并继续下载,已完成的部分不会重下;单个分片失败会自动重试,无需手动处理。
|
||||
|
||||
### 创建文件夹
|
||||
|
||||
```
|
||||
@@ -526,6 +535,7 @@ Flags:
|
||||
|
||||
> **两步下载流程**:先调用 MCP 工具获取历史版本下载 URL 和签名头,再 HTTP GET 下载文件内容到本地。
|
||||
> `--output` 指定目录时,优先从文件信息中获取原始文件名,获取不到时从下载 URL 推断。
|
||||
> 历史版本下载同样支持 `--part-size` / `--parallel` / `--no-resume` 分片下载参数,行为与最新版下载一致。
|
||||
|
||||
#### 回滚文件到指定历史版本
|
||||
|
||||
|
||||
@@ -163,6 +163,193 @@ func TestMockMCPSmoke_CLIRoutesSerializedArgumentsAndPrintsJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockMCPSmoke_ChatDownloadMediaJSONCLI(t *testing.T) {
|
||||
type mediaRequest struct {
|
||||
method string
|
||||
path string
|
||||
query string
|
||||
header string
|
||||
}
|
||||
|
||||
mediaPayload := []byte("synthetic media payload")
|
||||
var mediaRequestsMu sync.Mutex
|
||||
var mediaRequests []mediaRequest
|
||||
mediaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mediaRequestsMu.Lock()
|
||||
mediaRequests = append(mediaRequests, mediaRequest{
|
||||
method: r.Method,
|
||||
path: r.URL.Path,
|
||||
query: r.URL.RawQuery,
|
||||
header: r.Header.Get("X-Download-Fixture"),
|
||||
})
|
||||
mediaRequestsMu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(mediaPayload)
|
||||
}))
|
||||
defer mediaServer.Close()
|
||||
|
||||
downloadURL := mediaServer.URL + "/photo.jpg?fixture=one&part=two"
|
||||
downloadInfo, err := json.Marshal(map[string]any{
|
||||
"resourceUrl": downloadURL,
|
||||
"headers": map[string]string{
|
||||
"X-Download-Fixture": "synthetic",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requestsMu sync.Mutex
|
||||
var requests []recordedToolCall
|
||||
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
call := recordedToolCall{
|
||||
path: r.URL.Path,
|
||||
method: r.Method,
|
||||
authorization: r.Header.Get("Authorization"),
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&envelope); err != nil {
|
||||
call.err = err
|
||||
} else {
|
||||
call.jsonrpc = envelope.JSONRPC
|
||||
call.tool = envelope.Params.Name
|
||||
call.arguments = envelope.Params.Arguments
|
||||
if envelope.Method != "tools/call" {
|
||||
call.err = fmt.Errorf("JSON-RPC method = %q, want tools/call", envelope.Method)
|
||||
}
|
||||
}
|
||||
requestsMu.Lock()
|
||||
requests = append(requests, call)
|
||||
requestsMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": envelope.ID,
|
||||
"result": map[string]any{
|
||||
"content": []map[string]any{{
|
||||
"type": "text",
|
||||
"text": string(downloadInfo),
|
||||
}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer mcpServer.Close()
|
||||
|
||||
outputDir := t.TempDir()
|
||||
env := isolatedCLIEnv(t, map[string]string{
|
||||
"DINGTALK_IM_MCP_URL": mcpServer.URL + "/mcp/im",
|
||||
})
|
||||
args := []string{
|
||||
"--token", "ci-smoke-token",
|
||||
"--format", "json",
|
||||
"chat", "message", "download-media",
|
||||
"--type", "mediaId",
|
||||
"--resource-id", "resource-001",
|
||||
"--message-id", "message-001",
|
||||
"--open-conversation-id", "conversation-001",
|
||||
"--output", outputDir,
|
||||
}
|
||||
stdout, stderr, err := runCLI(t, env, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("dws chat message download-media failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
|
||||
}
|
||||
|
||||
requestsMu.Lock()
|
||||
recorded := append([]recordedToolCall(nil), requests...)
|
||||
requestsMu.Unlock()
|
||||
if len(recorded) != 1 {
|
||||
t.Fatalf("local fake MCP server received %d requests, want exactly one tools/call: %#v", len(recorded), recorded)
|
||||
}
|
||||
call := recorded[0]
|
||||
if call.err != nil {
|
||||
t.Fatal(call.err)
|
||||
}
|
||||
if call.path != "/mcp/im" || call.method != http.MethodPost || call.jsonrpc != "2.0" {
|
||||
t.Fatalf("MCP request = path %q method %q jsonrpc %q", call.path, call.method, call.jsonrpc)
|
||||
}
|
||||
if call.authorization != "Bearer ci-smoke-token" {
|
||||
t.Fatalf("Authorization = %q, want synthetic smoke token", call.authorization)
|
||||
}
|
||||
if call.tool != "get_resource_download_url" {
|
||||
t.Fatalf("tool = %q, want get_resource_download_url", call.tool)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"resourceType": "mediaId",
|
||||
"resourceId": "resource-001",
|
||||
"openMessageId": "message-001",
|
||||
"openConversationId": "conversation-001",
|
||||
}
|
||||
if !reflect.DeepEqual(call.arguments, wantArgs) {
|
||||
t.Fatalf("arguments = %#v, want %#v", call.arguments, wantArgs)
|
||||
}
|
||||
|
||||
mediaRequestsMu.Lock()
|
||||
recordedMedia := append([]mediaRequest(nil), mediaRequests...)
|
||||
mediaRequestsMu.Unlock()
|
||||
if len(recordedMedia) != 1 {
|
||||
t.Fatalf("media server received %d requests, want exactly one: %#v", len(recordedMedia), recordedMedia)
|
||||
}
|
||||
gotMedia := recordedMedia[0]
|
||||
if gotMedia.method != http.MethodGet || gotMedia.path != "/photo.jpg" {
|
||||
t.Fatalf("media request = method %q path %q", gotMedia.method, gotMedia.path)
|
||||
}
|
||||
if gotMedia.query != "fixture=one&part=two" {
|
||||
t.Fatalf("media query = %q, want fixture=one&part=two", gotMedia.query)
|
||||
}
|
||||
if gotMedia.header != "synthetic" {
|
||||
t.Fatalf("media header = %q, want synthetic", gotMedia.header)
|
||||
}
|
||||
|
||||
wantOutput := filepath.Join(outputDir, "photo.jpg")
|
||||
gotPayload, err := os.ReadFile(wantOutput)
|
||||
if err != nil {
|
||||
t.Fatalf("read downloaded file: %v", err)
|
||||
}
|
||||
if !bytes.Equal(gotPayload, mediaPayload) {
|
||||
t.Fatalf("downloaded payload = %q, want %q", gotPayload, mediaPayload)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &result); err != nil {
|
||||
t.Fatalf("CLI returned non-JSON stdout: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
|
||||
}
|
||||
if len(result) != 3 || result["success"] != true || result["downloadUrl"] != downloadURL || result["output"] != wantOutput {
|
||||
t.Fatalf("CLI result = %#v, want exact success/downloadUrl/output contract", result)
|
||||
}
|
||||
if strings.Contains(stdout, "[INFO]") {
|
||||
t.Fatalf("JSON stdout contains progress text: %s", stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "?fixture=one&part=two") {
|
||||
t.Fatalf("downloadUrl was escaped or changed: %s", stdout)
|
||||
}
|
||||
|
||||
publicResult := map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": "http://127.0.0.1:<fixture-port>/photo.jpg?fixture=one&part=two",
|
||||
"output": "./downloads/photo.jpg",
|
||||
}
|
||||
var publicOutput bytes.Buffer
|
||||
publicEncoder := json.NewEncoder(&publicOutput)
|
||||
publicEncoder.SetEscapeHTML(false)
|
||||
publicEncoder.SetIndent("", " ")
|
||||
if err := publicEncoder.Encode(publicResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log("CLI (public-safe): dws chat message download-media --type mediaId --resource-id resource-001 --message-id message-001 --open-conversation-id conversation-001 --output ./downloads/ --format json")
|
||||
t.Logf("stdout (public-safe):\n%s", strings.TrimSpace(publicOutput.String()))
|
||||
t.Logf("downloaded file: photo.jpg (%d bytes, content verified)", len(gotPayload))
|
||||
}
|
||||
|
||||
func isolatedCLIEnv(t *testing.T, extra map[string]string) []string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user