From b0764efe81b77644e766bd9defcfcc1b4dd58f05 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Thu, 20 Aug 2026 21:27:50 +0800 Subject: [PATCH] fix(history): hide removed delivery targets --- CONTEXT.md | 5 +- .../clipboard/sync/get_entry_delivery_view.rs | 214 +++++++++++++++++- .../src/facade/clipboard/facade.rs | 7 +- docs/architecture/architecture-bible.md | 2 + docs/specs/uc-engine-interface.md | 2 +- 5 files changed, 214 insertions(+), 16 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 3ee470b8..476a653a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -23,7 +23,8 @@ - **成员准入激活(Membership Admission Activation)**:新设备完整核对历史并暂存目标安全状态后,双方正式保存同一成员记录和安全状态,再解除普通权限门禁的过程。它不同于剪贴板内容的“激活”。 - **普通空间重置(Space Reset)**:清除当前设置和未消费邀请,并隐藏已结束加入结果的本机操作。存在未完成加入或恢复工作时拒绝执行;它保留防重放、历史验证和继续恢复所需的持久事实。 - **彻底空间重置(Factory Space Reset)**:停止并永久失效旧运行实例,删除本机密钥、空间资料和受管文件后建立全新本机状态的可恢复操作。它只证明本机资料已销毁,不会伪造远端拒绝、取消或成员移除。 -- **可信对等端(Trusted Peer)**:已完成身份确认、可作为配对或同步对象的设备关系;它不同于尚未完成验证的成员候选。 +- **当前成员范围(Current Member Scope)**:当前仍获准参与设备列表、连接、剪贴板发送、恢复和重发等普通功能的其他设备集合。它由已应用的成员历史决定;历史成员记录、可信关系、地址和旧发送记录都不能单独恢复当前资格。无法确认该范围时,普通功能必须失败,不得退回历史关系推断。 +- **可信对等端(Trusted Peer)**:已完成身份确认的设备关系;它不同于尚未完成验证的成员候选。设备被移除后,该关系可为历史验证和受限决定继续保留,但只有同时属于当前成员范围时才能参与普通功能。 - **成员移除(Member Removal)**:发起设备将移除写入签名成员历史并立即应用;其他设备收到后必须由本机用户接受或拒绝,不能自动移除成员。 - **成员历史核对(Membership Reconciliation)**:可信设备建立连接后自动比较并补齐签名成员历史。普通新增可以自动应用,未确认的移除必须等待本机用户决定。 - **待移除决定(Pending Removal Decision)**:本机收到有效但尚未接受的移除后,继续保留当前成员和内容权限,等待用户接受或拒绝;同一决定会跨重启保留。 @@ -44,7 +45,7 @@ - **自动同步(Automatic Sync)**:同步总开关开启时,对本机新内容自动发送和离线自动补送的选择。关闭后,用户仍可手动重发允许发送的内容或文件。 - **离线投递恢复(Offline Delivery Recovery)**:自动同步开启时,本机内容先立即投递;既有设备暂时不可达时,每台设备只保留最新一条本机内容作为自动补送目标。新的本机复制会替代该设备更早的待送内容;待其恢复在线或应用重启后重新发现在线时,只自动补送这一条。它不向新设备补送旧内容,不处理远端来源,不重复发送已送达或已被替代的内容;自动同步关闭、同步总开关关闭或内容无法恢复时停止自动补送。 - **已被替代(Superseded)**:同一设备有更新的本机内容后,更早的暂时未送达内容进入的最终状态。它不会再自动补送,也不表示发送失败。 -- **历史(History)**:已持久化的剪贴板条目及其检索、资源和投递状态集合。 +- **历史(History)**:已持久化的剪贴板条目及其检索、资源和投递状态集合。单条记录的发送情况只展示当前仍有效且可信的设备;已移除设备即使保留旧发送记录也不再显示,当前成员范围无法确认时查询失败。 - **普通远端接收(Interactive Receive)**:收到远端内容后保存到历史,并写入本机剪贴板的完整接收方式。 - **只保存拉取(Store-only Pull)**:收到远端内容后只保存到历史,不写入本机剪贴板的完整接收方式。 diff --git a/crates/uc-application/src/clipboard/sync/get_entry_delivery_view.rs b/crates/uc-application/src/clipboard/sync/get_entry_delivery_view.rs index 7b24889b..ffcc438a 100644 --- a/crates/uc-application/src/clipboard/sync/get_entry_delivery_view.rs +++ b/crates/uc-application/src/clipboard/sync/get_entry_delivery_view.rs @@ -2,18 +2,19 @@ //! //! 为什么需要这个模块: //! 持久化层只记"投递发生过的事实"(`EntryDeliveryRepositoryPort`),不记"未尝试" -//! 这种状态。视图层要回答的是"这条 entry 对每台可信对端目前的状态如何",这 -//! 是一个跨多个仓储的合成动作 —— entry 本身、来源(event)、当前可信对端集合、 -//! 已发生的投递事实四者差集合并,才能得出一个完整、不误导的视图。把这些拼接 +//! 这种状态。视图层要回答的是"这条 entry 对每台当前可信对端的状态如何",这 +//! 是一个跨多个仓储的合成动作 —— entry 本身、来源(event)、当前成员范围、 +//! 历史可信关系、已发生的投递事实合并,才能得出一个完整、不误导的视图。把这些拼接 //! 关在一个 use case 里,facade 上层只看一个动作:`get_entry_delivery_view`。 -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use uc_core::clipboard::{ DeliveryFailureReason, EntryDeliveryRecord, EntryDeliveryStatus as DomainDeliveryStatus, }; use uc_core::ids::{DeviceId, EntryId}; +use uc_core::membership::CurrentWorkspacePeerScopePort; use uc_core::mobile_sync::MobileDeviceId; use uc_core::ports::clipboard::GetClipboardEntryPort; use uc_core::ports::{ @@ -23,7 +24,7 @@ use uc_core::ports::{ use uc_core::trusted_peer::TrustedPeerRepositoryPort; use uc_core::MemberRepositoryPort; -/// 视图模型:某条 entry 的"来源 + 对每个可信对端的同步状态"完整快照。 +/// 视图模型:某条 entry 的"来源 + 对每个当前可信对端的同步状态"完整快照。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct EntryDeliveryView { pub entry_id: EntryId, @@ -47,8 +48,8 @@ pub enum EntrySource { Historical, } -/// 单个对端的同步状态视图。`Pending` 不来自数据库,而是"该对端属于可信集合 -/// 但尚未在 delivery 表里出现"时由视图层合成。 +/// 单个对端的同步状态视图。`Pending` 不来自数据库,而是"该对端同时属于当前 +/// 成员范围和可信集合,但尚未在 delivery 表里出现"时由视图层合成。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct EntryDeliveryTargetView { pub target_device_id: DeviceId, @@ -93,6 +94,7 @@ pub(crate) struct GetEntryDeliveryViewUseCase { entry_repo: Arc, event_repo: Arc, trusted_peer_repo: Arc, + peer_scope: Arc, entry_delivery_repo: Arc, device_identity: Arc, member_repo: Arc, @@ -104,6 +106,7 @@ impl GetEntryDeliveryViewUseCase { entry_repo: Arc, event_repo: Arc, trusted_peer_repo: Arc, + peer_scope: Arc, entry_delivery_repo: Arc, device_identity: Arc, member_repo: Arc, @@ -113,6 +116,7 @@ impl GetEntryDeliveryViewUseCase { entry_repo, event_repo, trusted_peer_repo, + peer_scope, entry_delivery_repo, device_identity, member_repo, @@ -204,9 +208,17 @@ impl GetEntryDeliveryViewUseCase { }); } - // 5. 本机 entry:trusted_peer 全集 LEFT JOIN delivery 表合成视图。 - // delivery 表中"孤儿"行(target 已不在 trusted_peer 全集)被 - // 自动忽略,这是有意的:用户解除配对后,UI 上不该再显示鬼魂设备。 + // 5. 本机 entry:当前成员范围与 trusted_peer 取交集后 LEFT JOIN + // delivery 表。可信关系和投递事实为历史验证保留,不能单独恢复当前资格。 + let current_peers: HashSet = self + .peer_scope + .snapshot() + .await + .map_err(|e| GetEntryDeliveryViewError::Storage(format!("current peer scope: {e:?}")))? + .peer_device_ids + .into_iter() + .collect(); + let trusted = self .trusted_peer_repo .list() @@ -232,6 +244,9 @@ impl GetEntryDeliveryViewUseCase { // 参数切换,而不是把 trusted_peer 集合外的也展示出来)。 for peer in trusted { let target_id = peer.peer_device_id; + if !current_peers.contains(&target_id) { + continue; + } let target_name = name_index.get(&target_id).cloned(); match delivery_index.get(target_id.as_str()) { Some(rec) => { @@ -304,6 +319,10 @@ mod tests { use std::sync::Mutex; use uc_core::clipboard::ClipboardEntry; use uc_core::ids::EventId; + use uc_core::membership::{ + CurrentWorkspaceLocalMembership, CurrentWorkspacePeerScopeError, + CurrentWorkspacePeerScopeSource, CurrentWorkspacePeerSnapshot, + }; use uc_core::mobile_sync::{MobileClientType, MobileDevice, MobileDeviceError}; use uc_core::security::IdentityFingerprint; use uc_core::trusted_peer::{TrustedPeer, TrustedPeerError}; @@ -401,6 +420,15 @@ mod tests { peers: Mutex::new(list), } } + + fn device_ids(&self) -> Vec { + self.peers + .lock() + .unwrap() + .iter() + .map(|peer| peer.peer_device_id.clone()) + .collect() + } } #[async_trait] impl TrustedPeerRepositoryPort for FakeTrustedPeerRepo { @@ -461,6 +489,33 @@ mod tests { } } + struct FixedPeerScope(Vec); + + #[async_trait] + impl CurrentWorkspacePeerScopePort for FixedPeerScope { + async fn snapshot( + &self, + ) -> Result { + Ok(CurrentWorkspacePeerSnapshot { + revision: 1, + source: CurrentWorkspacePeerScopeSource::CurrentHistory, + local_membership: CurrentWorkspaceLocalMembership::Active, + peer_device_ids: self.0.clone(), + }) + } + } + + struct FailingPeerScope; + + #[async_trait] + impl CurrentWorkspacePeerScopePort for FailingPeerScope { + async fn snapshot( + &self, + ) -> Result { + Err(CurrentWorkspacePeerScopeError::Unavailable) + } + } + /// 简易 SpaceMember 仓储 fake。`list` 返回构造时给的 (device_id, name) /// 列表;`fail_list = true` 时模拟 member_repo 故障,验证视图降级路径。 struct FakeMemberRepo { @@ -612,11 +667,33 @@ mod tests { delivery_repo: Arc, member_repo: Arc, mobile_device_repo: Arc, + ) -> GetEntryDeliveryViewUseCase { + let current_peers = trusted_peer_repo.device_ids(); + build_uc_full_with_scope( + entry_repo, + event_repo, + trusted_peer_repo, + delivery_repo, + member_repo, + mobile_device_repo, + Arc::new(FixedPeerScope(current_peers)), + ) + } + + fn build_uc_full_with_scope( + entry_repo: Arc, + event_repo: Arc, + trusted_peer_repo: Arc, + delivery_repo: Arc, + member_repo: Arc, + mobile_device_repo: Arc, + peer_scope: Arc, ) -> GetEntryDeliveryViewUseCase { GetEntryDeliveryViewUseCase::new( entry_repo, event_repo, trusted_peer_repo, + peer_scope, delivery_repo, Arc::new(FixedIdentity(local_id())), member_repo, @@ -780,6 +857,123 @@ mod tests { assert_eq!(view.deliveries[0].target_device_id, peer("p1")); } + #[tokio::test] + async fn removed_peer_with_retained_trust_and_delivery_is_excluded() { + let entries = Arc::new(FakeEntryRepo::new()); + entries.insert(make_entry("e1", "ev1", true)); + let events = Arc::new(FakeEventRepo::new()); + events.set_source(&event_id("ev1"), Some(local_id())); + let removed = peer("removed-peer"); + let uc = build_uc_full_with_scope( + entries, + events, + Arc::new(FakeTrustedPeerRepo::new(vec![removed.clone()])), + Arc::new(FakeDeliveryRepo::new(vec![delivered("e1", removed, 100)])), + Arc::new(FakeMemberRepo::new(vec![])), + Arc::new(make_empty_mobile_device_repo()), + Arc::new(FixedPeerScope(vec![])), + ); + + let view = uc.execute(&entry_id("e1")).await.unwrap(); + + assert!(view.deliveries.is_empty()); + } + + #[tokio::test] + async fn removed_peer_with_retained_trust_does_not_get_pending_status() { + let entries = Arc::new(FakeEntryRepo::new()); + entries.insert(make_entry("e1", "ev1", true)); + let events = Arc::new(FakeEventRepo::new()); + events.set_source(&event_id("ev1"), Some(local_id())); + let uc = build_uc_full_with_scope( + entries, + events, + Arc::new(FakeTrustedPeerRepo::new(vec![peer("removed-peer")])), + Arc::new(FakeDeliveryRepo::new(vec![])), + Arc::new(FakeMemberRepo::new(vec![])), + Arc::new(make_empty_mobile_device_repo()), + Arc::new(FixedPeerScope(vec![])), + ); + + let view = uc.execute(&entry_id("e1")).await.unwrap(); + + assert!(view.deliveries.is_empty()); + } + + #[tokio::test] + async fn current_trusted_peer_keeps_recorded_delivery_status() { + let entries = Arc::new(FakeEntryRepo::new()); + entries.insert(make_entry("e1", "ev1", true)); + let events = Arc::new(FakeEventRepo::new()); + events.set_source(&event_id("ev1"), Some(local_id())); + let current = peer("current-peer"); + let uc = build_uc_full_with_scope( + entries, + events, + Arc::new(FakeTrustedPeerRepo::new(vec![current.clone()])), + Arc::new(FakeDeliveryRepo::new(vec![delivered( + "e1", + current.clone(), + 100, + )])), + Arc::new(FakeMemberRepo::new(vec![])), + Arc::new(make_empty_mobile_device_repo()), + Arc::new(FixedPeerScope(vec![current])), + ); + + let view = uc.execute(&entry_id("e1")).await.unwrap(); + + assert_eq!(view.deliveries.len(), 1); + assert_eq!( + view.deliveries[0].status, + EntryDeliveryStatusView::Delivered + ); + } + + #[tokio::test] + async fn current_trusted_peer_without_delivery_record_is_pending() { + let entries = Arc::new(FakeEntryRepo::new()); + entries.insert(make_entry("e1", "ev1", true)); + let events = Arc::new(FakeEventRepo::new()); + events.set_source(&event_id("ev1"), Some(local_id())); + let current = peer("current-peer"); + let uc = build_uc_full_with_scope( + entries, + events, + Arc::new(FakeTrustedPeerRepo::new(vec![current.clone()])), + Arc::new(FakeDeliveryRepo::new(vec![])), + Arc::new(FakeMemberRepo::new(vec![])), + Arc::new(make_empty_mobile_device_repo()), + Arc::new(FixedPeerScope(vec![current])), + ); + + let view = uc.execute(&entry_id("e1")).await.unwrap(); + + assert_eq!(view.deliveries.len(), 1); + assert_eq!(view.deliveries[0].status, EntryDeliveryStatusView::Pending); + } + + #[tokio::test] + async fn current_peer_scope_failure_does_not_fall_back_to_retained_trust() { + let entries = Arc::new(FakeEntryRepo::new()); + entries.insert(make_entry("e1", "ev1", true)); + let events = Arc::new(FakeEventRepo::new()); + events.set_source(&event_id("ev1"), Some(local_id())); + let uc = build_uc_full_with_scope( + entries, + events, + Arc::new(FakeTrustedPeerRepo::new(vec![peer("removed-peer")])), + Arc::new(FakeDeliveryRepo::new(vec![])), + Arc::new(FakeMemberRepo::new(vec![])), + Arc::new(make_empty_mobile_device_repo()), + Arc::new(FailingPeerScope), + ); + + let err = uc.execute(&entry_id("e1")).await.unwrap_err(); + + assert!(matches!(err, GetEntryDeliveryViewError::Storage(_))); + } + // ── 分支 8: device_name 解析 — 命中/未命中 fallback ───────────────── // // SpaceMember 表里 p1 有名字、p2 是空字符串(等同缺失)、p3 完全不在 diff --git a/crates/uc-application/src/facade/clipboard/facade.rs b/crates/uc-application/src/facade/clipboard/facade.rs index a051b6ed..d8142570 100644 --- a/crates/uc-application/src/facade/clipboard/facade.rs +++ b/crates/uc-application/src/facade/clipboard/facade.rs @@ -70,9 +70,9 @@ pub struct ClipboardSyncDeps { /// 关联的发送路径(LocalCapture → outbound)才会触发实际写入,CLI / /// 测试路径走 entry_id=None 时本端口空跑。 pub entry_delivery_repo: Arc, - /// `get_entry_delivery_view` 拼装视图时需要 entry / event / trusted_peer - /// 三类仓储:entry 验存在 + 取 delivery_tracked,event 反查来源设备, - /// trusted_peer 给出"全集"用于合成 Pending。 + /// `get_entry_delivery_view` 拼装视图时需要 entry / event / trusted_peer: + /// entry 验存在 + 取 delivery_tracked,event 反查来源设备,trusted_peer + /// 与 `peer_scope` 取交集后用于合成 Pending。 pub entry_repo: Arc, pub event_repo: Arc, pub trusted_peer_repo: Arc, @@ -215,6 +215,7 @@ impl ClipboardSyncFacade { Arc::clone(&deps.entry_repo), Arc::clone(&deps.event_repo), Arc::clone(&deps.trusted_peer_repo), + Arc::clone(&deps.peer_scope), Arc::clone(&deps.entry_delivery_repo), Arc::clone(&deps.device_identity), Arc::clone(&deps.member_repo), diff --git a/docs/architecture/architecture-bible.md b/docs/architecture/architecture-bible.md index 9a40af9b..4565015e 100644 --- a/docs/architecture/architecture-bible.md +++ b/docs/architecture/architecture-bible.md @@ -941,6 +941,8 @@ node scripts/release/verify-release-bundle.mjs <产物目录> ## 文档维护记录 +- 2026-08-20:单条历史记录的发送视图改用当前成员范围与历史可信关系的交集;历史关系和旧发送事实继续保留,当前范围不可用时查询失败。该修改补齐既有当前成员范围规则,不改变分层或所有权。 + - 2026-08-20:全新安装首次启动确认没有既有设置后立即保存当前应用版本,避免完成设置后的第二次启动因版本游标仍为空而被误判为旧资料升级。既有资料缺少版本游标时仍进入旧资料独立化,并且只有独立化完整成功后才推进版本游标,失败时保留重试能力。不改变对外接口或持久化格式。 - 2026-08-20:旧资料升级隔离成功后立即推进应用版本游标,并在当前运行单元内标记为已执行;重新配对清除提醒并触发会话重建时,新的运行单元据此不得再次删除新成员。隔离失败不会推进游标,仍保留重试能力;重新配对提醒继续由独立的设置状态持久化,不因版本游标推进而提前消失。避免刚完成的重新配对在会话切换后被旧升级动作撤销。不改变对外接口或持久化格式。 diff --git a/docs/specs/uc-engine-interface.md b/docs/specs/uc-engine-interface.md index de688a38..587d8209 100644 --- a/docs/specs/uc-engine-interface.md +++ b/docs/specs/uc-engine-interface.md @@ -133,7 +133,7 @@ Running|Quiescing|Quiesced|Suspended -> ShuttingDown -> Stopped | `ReadBlob` | 读取指定 blob 的完整字节和媒体类型 | | `ReadThumbnail` | 读取指定表示的缩略图字节和媒体类型 | | `ReadEntryFile` | 读取指定记录的首个已物化文件及下载文件名 | -| `QueryEntryDelivery` | 返回指定记录的来源及每个可信设备的投递状态 | +| `QueryEntryDelivery` | 返回指定记录的来源及每个当前仍有效且可信设备的投递状态;已移除设备不显示,当前成员范围无法确认时查询失败 | | `ClearHistory` | 清空全部历史,并返回删除数量和未删除条目标识 | | `QueryEntryReceiveProgress` | 查询指定远端接收任务的当前聚合进度 | | `ListEntryReceiveProgress` | 列出全部尚未结束的远端接收任务进度 |