fix(presenter): 写设置那道门按(根,版本)判绑定 + 反查回落留日志 + 旗标改真名(M274 r2 P1-1)

This commit is contained in:
AskaEth committed 2026-11-12 19:55:52 +08:00
1 parent a614b49538
commit 70c6aec6dd
6 files changed
+258 -64

No files matched your search

+5 -5
View File
@@ -7,7 +7,7 @@ use crate::host::CoreHost;
use crate::host::HostEnv;
use crate::mock::MockBackend;
/// 启动选项(`--data-root` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。
/// 启动选项(`--data-dir` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。
#[derive(Debug, Clone, Default)]
pub struct AppOptions {
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析。
@@ -26,7 +26,7 @@ pub fn config_path(root: &Path) -> PathBuf {
/// 钉了数据根时用哪个环境(`None` = 没钉,走解析链)。
///
/// **单独一格只为可测**:`--data-root` 是产品通道,钉了数据根照样要看见官方启动器里的实例
/// **单独一格只为可测**:`--data-dir` 是产品通道,钉了数据根照样要看见官方启动器里的实例
/// (M274 r1 复审 P2-2)。退回 [`HostEnv::at_root`] 会让用户**静默**丢一半实例,而这层
/// `Box<dyn Backend>` 在用例里没有 env 取回口(`Backend` 没有访问器),所以判据就落在这个
/// 纯函数上(`backend::tests::data_root_keeps_discovering_the_official_launcher`)。
@@ -52,11 +52,11 @@ pub fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
mod tests {
use super::*;
/// `--data-root`(产品通道)**保留**多根发现;`at_root`(截图/演示/用例)不保留。
/// `--data-dir`(产品通道)**保留**多根发现;`at_root`(截图/演示/用例)不保留。
///
/// 抓得住:`host_env_for` 被改回 `HostEnv::at_root`——用户钉一个数据根就永久静默看不到
/// 官方启动器里的实例(M274 r1 复审 P2-2)。
/// 抓不住:`--data-root` 的命令行解析(app 侧);mounted 的数据根真的解析得出来(那是 resolver)。
/// 抓不住:`--data-dir` 的命令行解析(app 侧);mounted 的数据根真的解析得出来(那是 resolver)。
#[test]
fn data_root_keeps_discovering_the_official_launcher() {
let root = std::env::temp_dir().join(format!("ull-backend-m274-{}", std::process::id()));
@@ -76,7 +76,7 @@ mod tests {
unlucky_core::platform::paths::Platform::current(),
&unlucky_core::platform::paths::ProcessEnv,
),
"`--data-root` 是产品通道:不许顺手关掉官方根发现"
"`--data-dir` 是产品通道:不许顺手关掉官方根发现"
);
// 没钉数据根时这一格不参与(走解析链),`mock` 在前一步就分走了。
assert!(host_env_for(&AppOptions::default()).is_none());
+6 -6
View File
@@ -54,10 +54,10 @@ impl HostEnv {
Some(Self::from_data(data))
}
/// **钉根但照常做多根发现**:`--data-root`(生产通道)与 [`HostEnv::at_root`] 同一个落点,
/// **钉根但照常做多根发现**:`--data-dir`(生产通道)与 [`HostEnv::at_root`] 同一个落点,
/// 差别只有一格——`standard_game` 保留(用户钉的是数据根,不是「别看官方启动器」)。
///
/// 为什么两个入口必须分开(M274 r1 复审 P2-2):`--data-root` 是**产品**开关,之前它走
/// 为什么两个入口必须分开(M274 r1 复审 P2-2):`--data-dir` 是**产品**开关,之前它走
/// [`HostEnv::at_root`],于是用户一钉数据根就永久静默看不到官方启动器里的实例。而
/// [`HostEnv::at_root`] 还被截图 / 演示 / 七十多处用例用着,那些地方**必须**确定性
/// (不能因为开发机上装了官方启动器就让用例结果变),所以它继续不填 `standard_game`。
@@ -68,7 +68,7 @@ impl HostEnv {
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`。
///
/// **给谁用**:截图 / 演示 / 单元测试——凡是「结果必须与这台机器上装没装官方启动器无关」的地方。
/// **生产路径不要用这个**:产品里钉数据根(`--data-root`)走 [`HostEnv::at_data_root`],
/// **生产路径不要用这个**:产品里钉数据根(`--data-dir`)走 [`HostEnv::at_data_root`],
/// 那条会照常发现官方启动器里的实例;用错这里,用户会**静默**看不到官方实例(M274 r1 复审 P2-2)。
///
/// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**:
@@ -210,11 +210,11 @@ mod tests {
///
/// | 入口 | 谁用 | `standard_game` |
/// |---|---|---|
/// | [`HostEnv::at_data_root`] | 产品 `--data-root`(`backend.rs`) | `Some(官方位置)`:钉数据根 ≠ 别看官方实例 |
/// | [`HostEnv::at_data_root`] | 产品 `--data-dir`(`backend.rs`) | `Some(官方位置)`:钉数据根 ≠ 别看官方实例 |
/// | [`HostEnv::at_root`] | 截图 / 演示 / 七十多处用例 | `None`:同一份用例在任何机器上结果一样 |
/// | [`HostEnv::from_data`] | 生产解析链(便携 / 标准 / 自定义) | `Some(官方位置)` |
///
/// 抓得住:`--data-root` 退回 `at_root`(用户钉数据根后永久看不到官方实例,且没有任何报错)、
/// 抓得住:`--data-dir` 退回 `at_root`(用户钉数据根后永久看不到官方实例,且没有任何报错)、
/// 或 `at_root` 顺手开始发现(用例在装了官方启动器的机器上变红/变绿)。
/// 抓不住:`paths::standard_game_home` 自己算错(那是 core 的用例)。
///
@@ -240,7 +240,7 @@ mod tests {
assert_eq!(
HostEnv::at_data_root(&root).standard_game,
expected,
"`--data-root` 是产品通道:钉数据根不等于别看官方实例"
"`--data-dir` 是产品通道:钉数据根不等于别看官方实例"
);
// 两条入口除了 `standard_game` 之外必须**完全一致**(落点、模式)。
let pinned = HostEnv::at_root(&root);
+7 -12
View File
@@ -171,20 +171,12 @@ pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
let mut homes: Vec<Option<GameLayout>> = Vec::with_capacity(profiles.profiles.len());
let mut bound: Vec<&str> = Vec::new();
for profile in &profiles.profiles {
match found.find(profile.id.as_str()) {
Some((root, _)) => {
// 属于**这个根**的档案(懒创建的 `ext-` 档案可能属于官方根):磁贴按那个根描述自己。
if root.configured() {
// 两件事用**同一份**判据(`binds_configured_root` 与写盘那道门共用一个函数):
// ① 这条档案属于哪个根(磁贴按它描述自己);② 它是否占住配置根的同名版本。
if discovered::binds_configured_root(&found, profile) {
bound.push(profile.version_id.as_str());
}
homes.push(Some(root.game().clone()));
}
None => {
// 反查不到 ⇒ 按旧口径算配置根的(M273 之前合成的 `ext-` id / 那批根已不在盘上)。
bound.push(profile.version_id.as_str());
homes.push(None);
}
}
homes.push(found.game_of(profile.id.as_str()).cloned());
}
let mut snapshot: Vec<InstanceSummary> = profiles
.profiles
@@ -349,3 +341,6 @@ mod tests_multiroot;
#[cfg(test)]
mod tests_multiroot2;
#[cfg(test)]
mod tests_multiroot3;
+73 -40
View File
@@ -1,11 +1,16 @@
//! 自动发现的实例(M273 · 多根 M274):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
//!
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事:
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件三件事:
//!
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析(判据来自 `core::game::adopt::scan`,
//! 本文件只做「哪个根」与「有没有档案」这两步);
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析(判据来自 `core::game::adopt::scan`;
//! 本文件只负责「每个根扫一遍」并把结果装进 [`Found`]);
//! 2. **它们从哪个目录跑**:[`base_mode`]——`versions/<id>/` 里有痕迹(`mods` / `saves` /
//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**)。
//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**);
//! 3. **身份与判据**:[`external_instance_id`](id 里带根)、[`game_for`](读盘走哪个根)、
//! [`binds_configured_root`](这条档案占住的是**配置根**里那个版本吗——列表侧与写盘那道门共用)。
//!
//! 「哪个版本已经被档案绑定」这一步**不在本文件**:它属于 [`super::instances`] 的并集规则,
//! 而写盘那道门([`external_version_of`])用的是同一个 [`binds_configured_root`]。
//!
//! # 多根(M274):身份 = (根, 版本)
//!
@@ -13,9 +18,10 @@
//! [`external_instance_id`](把根的规范化路径喂进哈希)。于是「同一个根 + 同一个版本目录」跨重启
//! 拿到同一个 id,而两个根里的同名版本拿到两个不同 id——列表自然出两条。
//!
//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`locate`] 枚举全部根、每个根扫一遍
//! `versions/`,逐个比 id。因此它只在「用户动了一次某个实例」时被调用(存档 / 模组 / 启动 / 改设置),
//! 不在 `instances()` 的循环里。
//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`Found::scan`] 枚举全部根、每个根扫一遍
//! `versions/`,逐个比 id。**一拍只扫一遍**:`instances()` 建一份 [`Found`] 给整轮用(扫描次数 =
//! #根,r1 复审实测过旧形状是 `#根 × (1 + #ext 档案)`),单实例动作(存档 / 模组 / 启动 / 改设置)
//! 各建一份(同样是 #根 次)。
//!
//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节,
//! 更不往官方启动器的根里写任何东西。
@@ -23,7 +29,7 @@
use std::collections::BTreeMap;
use unlucky_core::game::isolation::IsolationMode;
use unlucky_core::game::{GameLayout, ProfileList};
use unlucky_core::game::{GameLayout, InstanceProfile, ProfileList};
use crate::dto::{InstanceRuntime, InstanceSummary};
@@ -160,24 +166,6 @@ pub(in crate::host) fn external_summary(
}
}
/// 视图 id → (所属根, 版本 id):**唯一的反查点**(哈希不可逆,只能重算比对)。
///
/// 与 [`external_versions`] 同一份判据(可登记 ⇒ 会出现在列表里),但**不**看档案绑定:
/// 一个自动发现的实例被懒创建成档案之后,id 仍然是 `ext-…`,而它的根必须还认得出来
/// (否则存档 / 模组 / 启动会退回配置根,那正是 M274 要修的错)。
///
/// 不是 `ext-` 前缀的 id 直接返回 `None`(档案实例属于配置根,不需要反查)。
///
/// **代价**(M274 r1 复审实测过):这是一次「每根扫一遍」的全树扫描。单实例动作(存档 / 模组 /
/// 启动 / 改设置)走这里没问题,**但绝不能在 `instances()` 的循环里调**——那里用 [`Found`]
/// 一拍只扫一遍(见它的文档)。
#[must_use]
pub(in crate::host) fn locate(env: &HostEnv, id: &str) -> Option<(GameRoot, String)> {
Found::scan(env)
.find(id)
.map(|(root, version_id)| (root.clone(), version_id.to_string()))
}
/// **一拍之内**的发现快照:每个根只扫一遍 `versions/`,之后所有「这个 id 属于哪个根」都是内存查表。
///
/// 为什么要有它(M274 r1 复审 P2-1 的实测):把反查直接写进 `instances()` 的循环里,扫描次数是
@@ -233,31 +221,76 @@ impl Found {
///
/// 档案实例、以及「反查不到」的 id 一律退回配置根 `env.game`——**没有第二个答案**:
/// 宁可读配置根(旧行为),也不把实例指向一个猜出来的目录。
///
/// # 退回的后果(M274 r2 复审 P2-1,任务⑥点名要写)
///
/// `is_discovered_instance(id)` 却反查不到,只有两种情形:
/// 1. **老档案**(M273 时代的 `ext-` id,哈希里没有根)——退回配置根是对的;
/// 2. **那个版本此刻不可登记**:官方启动器正在更新(清单半写 / 被锁 / 目录被移走)⇒
/// `adopt::scan` 判 `adoptable() == false` ⇒ 这里查不到。
///
/// 第 2 种情形下退回配置根是有代价的:**若配置根里恰好有同名版本**,装配会去读它、
/// `gameDir` 也落在配置根 ⇒ 可能**启动/读档到另一个根**。所以这里留一行启动器日志
/// (`logs/launcher.log`,分类 `multiroot`),让这条回落可观测,而不是让调用方无感。
pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout {
Found::scan(env)
.game_of(id)
.cloned()
.unwrap_or_else(|| env.game.clone())
let found = Found::scan(env);
if let Some(game) = found.game_of(id) {
return game.clone();
}
if crate::dto::is_discovered_instance(id) {
unlucky_core::launch::launcher_log::record(
"multiroot",
&format!(
"实例 {id} 反查不到所属根(版本此刻不可登记:清单半写/被锁/目录已不在)⇒ 按配置根 {} 解析",
env.game.root().display()
),
);
}
env.game.clone()
}
/// 视图 id → 版本 id 的反查(只认「可登记、且**还没有档案绑定**」的版本)。
/// 这条档案**占住的是配置根里的那个版本吗**(`instances()` 的 `bound` 与写盘那道门共用同一份判据)。
///
/// 与 `instances()` 同一份判据:合成的视图 id 只在那个集合里有意义,于是「界面上出现过的外部实例」
/// 与「能懒创建的外部实例」永远是同一批(有了档案就走档案,不再重复列一条)。
/// - 反查到某个根 ⇒ 看那个根是不是配置根;
/// - 反查不到 ⇒ 按配置根口径算(老 M273 的 `ext-` id、用户/向导建的自建档案、那批根已不在盘上)。
///
/// 为什么必须是**一个**函数(M274 r2 复审 P1-1):这两处曾经各写一份,列表侧对、写盘侧只比
/// `version_id`——于是官方根那个 `A` 被懒创建成档案后,**配置根**里同名那个 `A` 被判成
/// 「已被绑定」,`ensure_profile` 返回 `找不到实例 ext-…`,而磁贴还好端端列着。
/// 判据分叉的代价是「有时能改有时不能改」,最难排的那一种。
pub(in crate::host) fn binds_configured_root(found: &Found, profile: &InstanceProfile) -> bool {
found
.find(profile.id.as_str())
.is_none_or(|(root, _)| root.configured())
}
/// 视图 id → (所属根, 版本 id) 的反查(只认「可登记、且**还没有档案绑定**」的版本)。
///
/// 与 `instances()` 同一份判据(含 [`binds_configured_root`]):合成的视图 id 只在那个集合里有意义,
/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批(有了档案就走档案,
/// 不再重复列一条)。
#[must_use]
pub(in crate::host) fn external_version_of(
env: &HostEnv,
profiles: &ProfileList,
id: &str,
) -> Option<(GameRoot, String)> {
let (root, version_id) = locate(env, id)?;
let bound = profiles
.profiles
.iter()
.any(|profile| profile.version_id == version_id);
// 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,同一个 id 也不会撞)。
if root.configured() && bound {
// 一拍只扫一次:下面「查自己」与「查绑定者」都用这一份。
let found = Found::scan(env);
let (root, version_id) = found
.find(id)
.map(|(root, version_id)| (root.clone(), version_id.to_string()))?;
// 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,id 不会撞)。
// 而「绑定者」是不是配置根里的那一条,也按它**自己的根**判——不许只比 `version_id`:
// 官方根的 `A` 与配置根的 `A` 是两个实例(r2 复审 P1-1 的现场)。
if root.configured() {
let taken = profiles.profiles.iter().any(|profile| {
profile.version_id == version_id
&& (profile.id.as_str() == id || binds_configured_root(&found, profile))
});
if taken {
return None;
}
}
Some((root, version_id))
}
@@ -1,4 +1,7 @@
//! M274:**多根发现**(只读那条用例在 `tests_multiroot2.rs`:升级成内容级快照,M274 r1 P2-3)(配置根 + 官方启动器的标准位置)的判据用例。
//! M274:**多根发现**(配置根 + 官方启动器的标准位置)的判据用例。
//!
//! 只读那条用例搬到了 `tests_multiroot2.rs` 并升级成内容级快照(M274 r1 复审 P2-3);
//! 写设置那道门的两条方向用例在 `tests_multiroot3.rs`(M274 r2 复审 P1-1)。
//!
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
//! `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `<exe>/.minecraft`——两个不同的目录。
@@ -0,0 +1,163 @@
//! M274 **r2 复审 P1-1**:写设置那道门(`ensure_profile` → `external_version_of`)必须按
//! **(根, 版本)** 判「这条档案是否占住配置根的同名版本」,不许只比 `version_id`。
//!
//! 现场(复审探针,夹具就是 mission 判据⑤那份「两根各一个同名 `A`」):
//!
//! ```text
//! 官方那条 set_isolation => Ok(())
//! 配置根那条 set_isolation => Err("找不到实例 `ext-cf5883f9d893ad24`") ← 而磁贴还好好列着
//! ```
//!
//! 方向还是不对称的(先改配置根那条再改官方那条是通的)——同一台机器上「有时能改有时不能改」,
//! 最难排的那种。根因是列表侧(`instances()` 的 `bound`)按根判、写侧(`external_version_of`)
//! 只比 `version_id`;现在两处共用 [`super::discovered::binds_configured_root`]。
//!
//! | 用例 | 抓得住什么 | 抓不住什么 |
//! |---|---|---|
//! | [`both_same_named_instances_take_settings_in_either_order`] | 两个根的**同名**实例都改得动设置,两个方向都行,且写进去的值读得回来 | 图形界面点按钮那一路(`Element` 无内省;这里走的是同一条 host 函数) |
use std::path::PathBuf;
use unlucky_core::game::install::instance::{read_instance_config, Isolation};
use unlucky_core::game::GameLayout;
use super::*;
/// 临时根(每个用例一个目录;断言 panic 时也会删)。
mod temp {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
pub struct Root(pub PathBuf);
impl Root {
pub fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
}
impl Drop for Root {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(Path::new(&self.0));
}
}
}
/// 造两个根:配置根 = `<root>/.minecraft`,官方位置 = `<root>/official/.minecraft`。
fn two_roots(tag: &str) -> (temp::Root, HostEnv) {
let root = temp::Root::new(tag);
let mut env = HostEnv::at_root(&root.0);
let official = root.0.join("official").join(".minecraft");
std::fs::create_dir_all(&official).expect("官方根目录");
env.standard_game = Some(official);
(root, env)
}
fn official(env: &HostEnv) -> PathBuf {
env.standard_game.clone().expect("用例里一定钉了官方根")
}
fn write_version(game: &GameLayout, id: &str) {
let path = game.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, format!(r#"{{"id":"{id}"}}"#)).expect("写版本 JSON");
}
/// 列表里满足 `hit` 的那一条的 id(两条磁贴都含 `A`,所以判据写在调用处)。
fn tile_id(env: &HostEnv, hit: impl Fn(&crate::dto::InstanceSummary) -> bool) -> String {
instances(env)
.into_iter()
.find(|tile| hit(tile))
.unwrap_or_else(|| panic!("列表里没有那一条:{:?}", instances(env)))
.id
}
/// 两个根的**同名**实例都改得动设置:正序、反序各跑一遍,每条都断言「写进去读得回来」。
///
/// 变异证明(复审给的形状):把 `external_version_of` 的 `bound` 改回
/// 「只比 `version_id`」⇒ 正序那条(先官方、后配置根)立刻红,报
/// `找不到实例 ext-…`,而两个方向的第二条都还是绿——这正是它被抓到的现场形状。
#[test]
fn both_same_named_instances_take_settings_in_either_order() {
for official_first in [true, false] {
let (_root, env) = two_roots("ull-host-multiroot3-order");
write_version(&env.game, "A");
write_version(&GameLayout::new(official(&env)), "A");
// 官方根那条的名字带根路径;配置根那条保持版本目录名(见 `discovered::display_name`)。
let official_id = tile_id(&env, |tile| tile.name.contains("official"));
let configured_id = tile_id(&env, |tile| tile.name == "A");
assert_ne!(official_id, configured_id, "两个根的同名版本必须是两条实例");
let order: [(&str, &str); 2] = if official_first {
[(&official_id, "official"), (&configured_id, "configured")]
} else {
[(&configured_id, "configured"), (&official_id, "official")]
};
for (index, (id, which)) in order.iter().enumerate() {
// 两条生产入口铺开用:一条走 `set_isolation`,另一条走 `apply_launch_args`。
if index == 0 {
set_isolation(&env, id, "isolated").expect("改隔离档不该报「找不到实例」");
let config = read_instance_config(&env.layout, id)
.expect("读实例设置")
.expect("实例设置文件应当已写盘");
assert_eq!(
config.isolation,
Some(Isolation::Isolated),
"{which} 那条写进去的隔离档读不回来"
);
} else {
let patch = crate::dto::LaunchArgsPatch {
jvm_args: Some("-XX:+UseG1GC".to_string()),
..crate::dto::LaunchArgsPatch::default()
};
let mut host = super::super::CoreHost::headless(env.clone());
host.apply_launch_args(id, &patch)
.expect("写启动参数不该报「找不到实例」");
let config = read_instance_config(&env.layout, id)
.expect("读实例设置")
.expect("实例设置文件应当已写盘");
assert_eq!(
config.launch.jvm_args.as_deref(),
Some("-XX:+UseG1GC"),
"{which} 那条写进去的启动参数读不回来"
);
}
// 写完一次之后,另一条**还在列表里**、而且还能继续改(不许被吞掉)。
let snapshot = instances(&env);
assert_eq!(
snapshot.len(),
2,
"写了 {which} 那条之后列表变了:{snapshot:?}"
);
assert!(
snapshot.iter().any(|tile| tile.id == official_id),
"官方根那条消失了:{snapshot:?}"
);
assert!(
snapshot.iter().any(|tile| tile.id == configured_id),
"配置根那条消失了:{snapshot:?}"
);
}
// 两条都写过之后:id → 根的反查不许漂(写盘与读盘同一份身份)。
assert_eq!(
game_for(&env, &official_id).root(),
official(&env),
"官方根那条写完设置之后反查退回了配置根"
);
assert_eq!(
game_for(&env, &configured_id).root(),
env.game.root(),
"配置根那条反查错了根"
);
let profiles = read_profiles(&env.layout).expect("读档案");
assert_eq!(profiles.profiles.len(), 2, "两条实例各一份档案,不多不少");
}
}