// 断点续传下载:利用 `download_to_path`「失败残留部分文件」的现状,
// 以本地已有字节数为起点发 `Range: bytes=N-` 只拉剩余部分;
// 配 `If-Range` 校验服务端资源未变,变了就整体重写,不拼出损坏文件。
// 底层区间下载在 download_ops.mbt(RL-04 按职责拆文件)。
///|
/// 断点续传下载到本地文件:本地已有前 N 字节(上次中断的残留)时发
/// `Range: bytes=N-` 续传,只拉取剩余部分追加到文件尾;本地不存在 /
/// 为空时等同于 `download_to_path` 全量下载。
///
/// - `etag`:上次响应的 ETag(`get_file` / `put_file` 的结果里都有)。
/// 提供则随请求发 `If-Range`:服务端资源仍是这一版才回 206 续传,
/// 已被修改则回 200 全文,本方法自动截断重写,保证不拼出损坏文件;
/// 省略则裸续传(服务端文件在两次下载之间被整体替换时可能得到
/// 拼接损坏的结果,调用方自行权衡);
/// - 206 的 `Content-Range` 缺失、或区间起点与本地字节数不符时抛
/// `Api`(此时继续追加不安全);
/// - 416(区间不满足)且本地字节数恰为服务端告知的资源总长时,
/// 视为「本地已完整」直接成功返回;否则抛 `Api(416)`;
/// - 下载完成后校验本地文件大小与服务端总长一致(总长已知时),
/// 不一致抛 `Api`,保证「成功返回 = 文件完整」;
/// - `on_progress` 报告**绝对**进度:`loaded` 从本地已有字节起累计,
/// `total` 为资源总长(`Content-Range` / `Content-Length` 均未给出
/// 时为 `None`)。
pub async fn WebdavClient::download_to_path_resume(
self : WebdavClient,
path~ : String,
local_path~ : String,
etag? : String,
chunk_size? : Int32,
on_progress? : (@types.Progress) -> Unit,
signal? : @moonhttp.AbortSignal,
) -> Unit raise @types.WebdavError {
let size = download_chunk_size(chunk_size)
let offset = resume_offset(local_path)
if offset == 0 {
// 没有可续传的本地内容:退化为普通全量下载
return self.download_to_path(
path~,
local_path~,
chunk_size?,
on_progress?,
signal?,
)
}
let url = self.url_for(path)
let mut cfg = @config.Config::new(url)
.with_method(@config.Method::Get)
.with_header("Range", @types.ByteRange::from(start=offset).header_value())
match etag {
Some(etag) => cfg = cfg.with_header("If-Range", etag)
None => ()
}
let stream = self.http.stream(cfg, signal?) catch {
error => raise map_http_error(error, signal_reason(signal))
}
if stream.status == 416 {
// 区间不满足:唯一合法情形是本地已经下完(offset == 资源总长)
let total = match stream.headers.get("Content-Range") {
Some(text) => @types.parse_unsatisfied_range_total(text)
None => None
}
stream.close()
if total is Some(total) && total == offset {
return
}
let hint = match total {
Some(total) => "本地 \{offset} 字节,服务器总长 \{total} 字节"
None => "本地 \{offset} 字节,服务器未告知总长"
}
raise @types.WebdavError::api(
status=416,
operation="GET",
message="区间不满足(\{hint})",
)
}
if stream.status == 200 {
// 服务端忽略 Range(不支持),或 If-Range 判定资源已变更:
// 只能整体重下,Content-Length 即资源总长
let total = read_content_length(stream.headers)
write_stream_to_file(
stream,
local_path~,
append=false,
offset=0,
total~,
chunk_size=size,
on_progress?,
signal?,
)
verify_resumed_size(local_path, total)
return
}
if stream.status == 206 {
match parse_content_range_header(stream.headers) {
Some(range) if range.first == offset => {
// 追加续写;进度用绝对值,总长以 Content-Range 为准
// (206 的 Content-Length 是区间长度,不是资源总长)
write_stream_to_file(
stream,
local_path~,
append=true,
offset~,
total=range.total,
chunk_size=size,
on_progress?,
signal?,
)
verify_resumed_size(local_path, range.total)
return
}
Some(range) => {
stream.close()
raise @types.WebdavError::api(
status=206,
operation="GET",
message="服务端返回的区间起点(\{range.first})与本地已有字节数(\{offset})不符,续传中止",
)
}
None => {
stream.close()
raise @types.WebdavError::api(
status=206,
operation="GET",
message="206 响应缺少可解析的 Content-Range 头,无法确认续传区间",
)
}
}
}
let status = stream.status
stream.close()
raise @types.WebdavError::api(
status~,
operation="GET",
message=status_message(status),
)
}
///|
/// 续传起点:本地已有字节数;文件不存在按 0(全量下载)。
/// 超出 Int32 表示范围(>2GB)的文件不支持续传,抛 `Io`。
async fn resume_offset(local_path : String) -> Int32 raise @types.WebdavError {
let exists = @fs.exists(local_path) catch { _ => false }
if !exists {
return 0
}
let size = @fs.file_size(local_path) catch {
_ =>
raise @types.WebdavError::io(
message="读取本地文件大小失败(\{local_path})",
)
}
if size > 2147483647L {
raise @types.WebdavError::io(
message="本地文件超过 2GB(\{local_path}),不支持断点续传",
)
}
size.to_int()
}
///|
/// 续传完成后的完整性校验:总长已知时本地文件大小必须一致。
async fn verify_resumed_size(
local_path : String,
total : Int32?,
) -> Unit raise @types.WebdavError {
match total {
None => ()
Some(total) => {
let size = @fs.file_size(local_path) catch {
_ =>
raise @types.WebdavError::io(
message="读取本地文件大小失败(\{local_path})",
)
}
if size != total.to_int64() {
raise @types.WebdavError::api(
status=206,
operation="GET",
message="下载不完整:本地 \{size} 字节,服务器总长 \{total} 字节",
)
}
}
}
}