fix(addon): 复审 r1 —— 失败去重、null 载荷、完成态进度、顺序守门、下载失败归并

- P2-1 失败分支不再「直接 continue」:`fail()` 记一条失败(`record_failure` 按
  `requested_as` 去重)并把请求键写进 `alias`,后续同键走现成的 `fold_parents`
  折父边 ⇒ 同一项目只取一次版本、失败只一条、重试名单不重复;
- P2-2 `RawDependency.dependency_type` 改 `Option<String>`:`#[serde(default)]` 兜不住
  `null`,以前一份 `"dependency_type": null` 就让整份 `/version` 报 Malformed、整个项目装不上;
  现在 null/未知一律 `Unknown`(不递归);
- P2-3 完成态用 `finish()` 把 `files_done` 对齐 `files_total`(曾写死 `1`,装 3 个停在 1/3);
  解析前 `files_total` 归 0(未知态),不再假装「已知 1 个文件」;
- P2-4 新增 `tests/order.rs`:跨根的层内顺序 + 多根正序/逆序对照,删 `layer.sort_by` 会红;
  另加 `required_project_ids` 自己的排序去重契约用例(`tests/regressions.rs`);
- P2-5 下载阶段失败记进同一套两档结构(`Unreachable` + 叶子成因)并继续下一个文件:
  已下好的不回滚、失败项进可重试名单、文案保留「已装入 N 个」的上下文;`retry_request`
  自动覆盖它们;
- nit① 在 `plan.rs` 写清「不兼容」为什么落在 DTO 的 `Unsupported`(不是笔误,是缺档);
  nit② 新增 `DependencyPlan::unresolved_required`(只有版本 id 的必备前置不再静默忽略);
  nit③ 补「同 mod 另一个版本在目录里仍要下载」的负向用例。
This commit is contained in:
AskaEth committed 2026-11-08 20:23:56 +08:00
1 parent 011d525e8d
commit aa33210bb6
12 files changed
+686 -77

No files matched your search

+52 -14
View File
@@ -21,6 +21,10 @@
//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次,
//! `DependencyPlan::retry_request()` 已经把请求备好了。
//!
//! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断,
//! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进
//! `DependencyPlan::record_failure`(`Unreachable` + 叶子成因),已下好的不回滚,最后一起收口。
//!
//! ## 光影与数据包:支持到哪一步(M186 核对过,别写成「都支持」)
//!
//! | 内容 | 检索 | 装入 | 差在哪 |
@@ -40,7 +44,8 @@ use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use unlucky_core::addon::online::{
download_verified, resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode,
download_verified, resolve, DependencyFailure, EngineArtifactFetcher, EngineTransport,
FailureKind, Modrinth, PlannedNode,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::game::install::instance::read_instance_config;
@@ -53,7 +58,7 @@ use crate::dto::{
use super::super::env::HostEnv;
use super::super::job::{HostTask, JobSender};
use super::{plan, sources::failure_of};
use super::plan;
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
pub(super) fn install(
@@ -67,7 +72,8 @@ pub(super) fn install(
version: request.title.clone(),
instance: request.instance.clone(),
loader: request.loader.label().to_string(),
files_total: 1,
// 文件数要等解析完才知道(含前置),这里留 0 = 未知(面板画不确定态)。
files_total: 0,
..InstallProgressView::new()
};
@@ -89,8 +95,7 @@ pub(super) fn install(
});
match run(env, request, cancel, &mut progress, tx, epoch) {
Ok(done) => {
progress.stage = InstallStage::Done;
progress.files_done = 1;
finish(&mut progress);
progress.message = done;
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
@@ -161,7 +166,7 @@ fn run(
plan::present_files(&target_dir),
);
let core_engine = super::super::engine::engine(env);
let dependency_plan = {
let mut dependency_plan = {
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
block_on(resolve(&client, &resolve_request))
@@ -189,11 +194,13 @@ fn run(
});
let fetcher = EngineArtifactFetcher::new(&core_engine);
for node in &dependency_plan.nodes {
let mut installed: Vec<PlannedNode> = Vec::new();
for index in 0..dependency_plan.nodes.len() {
if cancel.load(Ordering::SeqCst) {
return Err(local("已取消(下载中途)"));
}
progress.message = node_label(node);
let node = dependency_plan.nodes[index].clone();
progress.message = node_label(&node);
let _ = tx.send(HostTask::Install {
progress: Box::new(progress.clone()),
epoch,
@@ -201,15 +208,36 @@ fn run(
// M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核,
// 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。
let dest = target_dir.join(safe_file_name(&node.file.filename));
block_on(download_verified(
let outcome = block_on(download_verified(
&fetcher,
&node.file.url,
node.file.sha1(),
node.file.sha512(),
(node.file.size > 0).then_some(node.file.size),
&dest,
))
.map_err(failure_of)?;
));
match outcome {
Ok(_) => installed.push(node),
Err(error) => {
// 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因):
// 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。
// 以前这里 `.map_err(...)?` 直接中断,用户看不到「主文件已装、缺 fabric-api」。
dependency_plan.record_failure(DependencyFailure {
requested_as: node.requested_as.clone(),
required_by: node.required_by.clone(),
depth: node.depth,
kind: FailureKind::Unreachable,
cause: Some(error.kind()),
message: format!(
"下载 {} 失败({}):{}",
node.file.filename,
error.kind().as_str(),
error.message()
),
});
continue;
}
}
progress.files_done += 1;
let _ = tx.send(HostTask::Install {
progress: Box::new(progress.clone()),
@@ -217,11 +245,21 @@ fn run(
});
}
// 解析阶段的失败在这里收口:两档分成两句话(不合并成「下载失败」),并列出可只重试的项目。
if let Some(failure) = plan::failure_of_plan(&dependency_plan) {
// 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」),
// 并列出「可只重试这 N 个」。
if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) {
return Err(failure);
}
Ok(plan::summary(&dependency_plan))
Ok(plan::summary(&dependency_plan, &installed))
}
/// 完成态:`files_done` 对齐 `files_total`。
///
/// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态
/// 进度条 ⇒ 装完了条还停在 1/3(全部跳过时更糟,`1/0`)。
pub(super) fn finish(progress: &mut InstallProgressView) {
progress.stage = InstallStage::Done;
progress.files_done = progress.files_total;
}
/// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。