From d1c6edd57a4f3557b2e3d7bd4d62c06228b17bab Mon Sep 17 00:00:00 2001 From: wangdl Date: Thu, 11 Jun 2026 20:59:43 +0800 Subject: [PATCH] feat: ReadingMaterialRef V2 (DOC-FULL-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ReadingMaterialRefV2: materialId/filePath/fileName/fileType (camelCase) + mimeType/fileSize/contentHash/createdAtMs (可选) - V1 ReadingMaterialRef 标记 deprecated,保留向后兼容 - session_v2 迁移到 V2 类型 - FFI 层暴露 V2 类型,start_reading_session_v2 接收 V2 - 明确禁止字段: readingTargetType/userId/knowledgeBaseId - 160 tests passed / 0 failed Co-Authored-By: Claude Opus 4.7 --- crates/zx_document_core/src/events_v2.rs | 4 +- .../zx_document_core/src/reading_material.rs | 131 +++++++++++++++++- crates/zx_document_core/src/session_v2.rs | 16 +-- crates/zx_document_ffi/src/lib.rs | 10 +- 4 files changed, 144 insertions(+), 17 deletions(-) diff --git a/crates/zx_document_core/src/events_v2.rs b/crates/zx_document_core/src/events_v2.rs index 17df2f4..91012a9 100644 --- a/crates/zx_document_core/src/events_v2.rs +++ b/crates/zx_document_core/src/events_v2.rs @@ -241,13 +241,13 @@ mod tests { use std::sync::Mutex; use super::*; - use crate::reading_material::ReadingMaterialRef; + use crate::reading_material::ReadingMaterialRefV2; static TEST_LOCK: Mutex<()> = Mutex::new(()); fn setup_session(material_id: &str) -> String { // Lock is held for the duration of each test via the caller. - let mat = ReadingMaterialRef::new(material_id); + let mat = ReadingMaterialRefV2::new(material_id, "/tmp/test.md", "test.md", "markdown"); let id = session_v2::start_reading_session_v2(mat, 1000).unwrap(); clear_all_events_v2(); id diff --git a/crates/zx_document_core/src/reading_material.rs b/crates/zx_document_core/src/reading_material.rs index d257c50..8438fab 100644 --- a/crates/zx_document_core/src/reading_material.rs +++ b/crates/zx_document_core/src/reading_material.rs @@ -1,14 +1,16 @@ use serde::{Deserialize, Serialize}; -/// Reference to a reading material. +/// V1 reference to a reading material (deprecated – use ReadingMaterialRefV2). /// /// Rust does not know whether this is a KnowledgeSource or TemporaryReadingMaterial. /// readingTargetType is added by the iOS/API layer. +#[deprecated(since = "0.2.0", note = "use ReadingMaterialRefV2 instead")] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Record)] pub struct ReadingMaterialRef { pub material_id: String, } +#[allow(deprecated)] impl ReadingMaterialRef { pub fn new(material_id: impl Into) -> Self { Self { @@ -17,22 +19,145 @@ impl ReadingMaterialRef { } } +// ── V2 ── + +/// V2 reading material reference. +/// +/// Contains the minimal file-level information Rust needs. +/// Does NOT contain: +/// - readingTargetType (added by iOS/API) +/// - userId +/// - knowledgeBaseId +/// - platform / appVersion +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Record)] +#[serde(rename_all = "camelCase")] +pub struct ReadingMaterialRefV2 { + pub material_id: String, + pub file_path: String, + pub file_name: String, + pub file_type: String, + pub mime_type: Option, + pub file_size: Option, + pub content_hash: Option, + pub created_at_ms: Option, +} + +impl ReadingMaterialRefV2 { + pub fn new( + material_id: impl Into, + file_path: impl Into, + file_name: impl Into, + file_type: impl Into, + ) -> Self { + Self { + material_id: material_id.into(), + file_path: file_path.into(), + file_name: file_name.into(), + file_type: file_type.into(), + mime_type: None, + file_size: None, + content_hash: None, + created_at_ms: None, + } + } + + pub fn with_mime(mut self, mime_type: impl Into) -> Self { + self.mime_type = Some(mime_type.into()); + self + } + + pub fn with_file_size(mut self, file_size: u64) -> Self { + self.file_size = Some(file_size); + self + } + + pub fn with_content_hash(mut self, hash: impl Into) -> Self { + self.content_hash = Some(hash.into()); + self + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn test_new() { + fn test_v1_new() { let r = ReadingMaterialRef::new("src_123"); assert_eq!(r.material_id, "src_123"); } #[test] - fn test_serde() { + fn test_v1_serde() { let r = ReadingMaterialRef::new("src_456"); let json = serde_json::to_string(&r).unwrap(); assert!(json.contains("src_456")); let back: ReadingMaterialRef = serde_json::from_str(&json).unwrap(); assert_eq!(back, r); } + + // ── V2 tests ── + + #[test] + fn test_v2_new_minimal() { + let r = ReadingMaterialRefV2::new("mat_1", "/tmp/test.md", "test.md", "markdown"); + assert_eq!(r.material_id, "mat_1"); + assert_eq!(r.file_path, "/tmp/test.md"); + assert_eq!(r.file_name, "test.md"); + assert_eq!(r.file_type, "markdown"); + assert!(r.mime_type.is_none()); + assert!(r.file_size.is_none()); + assert!(r.content_hash.is_none()); + assert!(r.created_at_ms.is_none()); + } + + #[test] + fn test_v2_builder() { + let r = ReadingMaterialRefV2::new("mat_2", "/tmp/doc.pdf", "doc.pdf", "pdf") + .with_mime("application/pdf") + .with_file_size(102400) + .with_content_hash("abc123def"); + assert_eq!(r.mime_type.as_deref(), Some("application/pdf")); + assert_eq!(r.file_size, Some(102400)); + assert_eq!(r.content_hash.as_deref(), Some("abc123def")); + } + + #[test] + fn test_v2_serde_camel_case() { + let r = ReadingMaterialRefV2::new("mat_3", "/a/b.epub", "b.epub", "epub") + .with_mime("application/epub+zip"); + let json = serde_json::to_string(&r).unwrap(); + // must use camelCase keys + assert!(json.contains("\"materialId\"")); + assert!(json.contains("\"filePath\"")); + assert!(json.contains("\"fileName\"")); + assert!(json.contains("\"fileType\"")); + assert!(json.contains("\"mimeType\"")); + assert!(!json.contains("material_id")); + assert!(!json.contains("file_path")); + + let back: ReadingMaterialRefV2 = serde_json::from_str(&json).unwrap(); + assert_eq!(back.file_type, "epub"); + } + + #[test] + fn test_v2_serde_roundtrip() { + let r = ReadingMaterialRefV2::new("mat_4", "/z/t.txt", "t.txt", "text") + .with_file_size(42); + let json = serde_json::to_string(&r).unwrap(); + let back: ReadingMaterialRefV2 = serde_json::from_str(&json).unwrap(); + assert_eq!(back, r); + } + + #[test] + fn test_v2_no_forbidden_fields() { + // Verify JSON contains no business-layer fields + let r = ReadingMaterialRefV2::new("mat_5", "/x", "x.md", "markdown"); + let json = serde_json::to_string(&r).unwrap(); + assert!(!json.contains("readingTargetType")); + assert!(!json.contains("userId")); + assert!(!json.contains("knowledgeBaseId")); + assert!(!json.contains("platform")); + assert!(!json.contains("appVersion")); + } } diff --git a/crates/zx_document_core/src/session_v2.rs b/crates/zx_document_core/src/session_v2.rs index 776f85c..84cfd9a 100644 --- a/crates/zx_document_core/src/session_v2.rs +++ b/crates/zx_document_core/src/session_v2.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::Mutex; use crate::progress::ReadingPosition; -use crate::reading_material::ReadingMaterialRef; +use crate::reading_material::ReadingMaterialRefV2; use crate::time_tracker::ActiveTimeTracker; fn sessions() -> &'static Mutex> { @@ -27,7 +27,7 @@ pub enum ReadingSessionStatus { #[derive(Debug, Clone, uniffi::Record)] pub struct ReadingSessionV2 { pub client_session_id: String, - pub material: ReadingMaterialRef, + pub material: ReadingMaterialRefV2, pub started_at_ms: i64, pub last_event_at_ms: i64, pub next_sequence: u64, @@ -38,7 +38,7 @@ pub struct ReadingSessionV2 { /// Start a new reading session. Returns the client_session_id as handle. pub fn start_reading_session_v2( - material: ReadingMaterialRef, + material: ReadingMaterialRefV2, timestamp_ms: i64, ) -> Result { let session_id = uuid::Uuid::new_v4().to_string(); @@ -248,8 +248,8 @@ impl std::fmt::Display for SessionError { mod tests { use super::*; - fn test_material() -> ReadingMaterialRef { - ReadingMaterialRef::new("test_mat_001") + fn test_material() -> ReadingMaterialRefV2 { + ReadingMaterialRefV2::new("test_mat_001", "/tmp/test.md", "test.md", "markdown") } fn teardown(id: &str) { @@ -327,7 +327,7 @@ mod tests { #[test] fn test_two_sessions_independent() { let a = start_reading_session_v2(test_material(), 1000).unwrap(); - let b = start_reading_session_v2(ReadingMaterialRef::new("mat_b"), 2000).unwrap(); + let b = start_reading_session_v2(ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown"), 2000).unwrap(); assert_ne!(a, b); record_session_event_v2(&a, 3000, 0, None).unwrap(); record_session_event_v2(&b, 4000, 0, None).unwrap(); @@ -350,7 +350,7 @@ mod tests { #[test] fn test_sequence_and_seconds_independent() { let a = start_reading_session_v2(test_material(), 0).unwrap(); - let b = start_reading_session_v2(ReadingMaterialRef::new("mat_b"), 0).unwrap(); + let b = start_reading_session_v2(ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown"), 0).unwrap(); record_session_event_v2(&a, 10_000, 10, None).unwrap(); record_session_event_v2(&b, 5_000, 5, None).unwrap(); record_session_event_v2(&b, 10_000, 5, None).unwrap(); @@ -380,7 +380,7 @@ mod tests { record_session_event_v2(&old, 1000, 0, None).unwrap(); // Recent session: last event at t=19000 (just 1s ago at now=20000) - let recent = start_reading_session_v2(ReadingMaterialRef::new("mat_recent"), 10000).unwrap(); + let recent = start_reading_session_v2(ReadingMaterialRefV2::new("mat_recent", "/tmp/r.md", "r.md", "markdown"), 10000).unwrap(); record_session_event_v2(&recent, 19000, 0, None).unwrap(); // At t=20000, clean up sessions inactive for > 5000ms diff --git a/crates/zx_document_ffi/src/lib.rs b/crates/zx_document_ffi/src/lib.rs index 9403883..ae8a3f7 100644 --- a/crates/zx_document_ffi/src/lib.rs +++ b/crates/zx_document_ffi/src/lib.rs @@ -11,7 +11,9 @@ pub use zx_document_core::search::SearchResult; pub use zx_document_core::anchors::NoteAnchor; pub use zx_document_core::progress::ReadingPosition; pub use zx_document_core::events::ReadingEvent; +#[allow(deprecated)] pub use zx_document_core::reading_material::ReadingMaterialRef; +pub use zx_document_core::reading_material::ReadingMaterialRefV2; pub use zx_document_core::events_v2::{ReadingEventV2, ReadingEventTypeV2}; pub use zx_document_core::epub::{EpubMetadata, EpubChapter}; pub use zx_document_core::office::{OfficePreviewConfig, OfficePreviewStrategy}; @@ -23,7 +25,7 @@ use zx_document_core::blocks as core_blocks; // ── V2 Reading Session FFI ── #[uniffi::export] -fn start_reading_session_v2(material: ReadingMaterialRef, timestamp_ms: i64) -> Result { +fn start_reading_session_v2(material: ReadingMaterialRefV2, timestamp_ms: i64) -> Result { zx_document_core::session_v2::start_reading_session_v2(material, timestamp_ms) .map_err(|e| e.to_string()) } @@ -666,7 +668,7 @@ mod ffi_tests { drain_buffer(); events_v2::clear_all_events_v2(); - let mat = ReadingMaterialRef::new("mat_ffi_test".to_string()); + let mat = ReadingMaterialRefV2::new("mat_ffi_test", "/tmp/test.md", "test.md", "markdown"); let sid = start_reading_session_v2(mat, 1000).unwrap(); assert!(!sid.is_empty()); @@ -709,7 +711,7 @@ mod ffi_tests { #[test] fn test_session_lifecycle() { - let mat = ReadingMaterialRef::new("mat_ffi_life".to_string()); + let mat = ReadingMaterialRefV2::new("mat_ffi_life", "/tmp/life.md", "life.md", "markdown"); let sid = start_reading_session_v2(mat, 0).unwrap(); pause_reading_session_v2(sid.clone()).unwrap(); resume_reading_session_v2(sid.clone()).unwrap(); @@ -724,7 +726,7 @@ mod ffi_tests { drain_buffer(); events_v2::clear_all_events_v2(); - let mat = ReadingMaterialRef::new("mat_ffi_recover".to_string()); + let mat = ReadingMaterialRefV2::new("mat_ffi_recover", "/tmp/recover.md", "recover.md", "markdown"); let sid = start_reading_session_v2(mat, 0).unwrap(); let e = push_material_opened_v2(sid.clone(), "mat_ffi_recover".to_string(), 1000).unwrap();