feat: ReadingMaterialRef V2 (DOC-FULL-001)

- 新增 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 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-11 20:59:43 +08:00
parent a81a9d7e1f
commit c276503b0d
4 changed files with 144 additions and 17 deletions

View File

@ -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

View File

@ -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<String>) -> 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<String>,
pub file_size: Option<u64>,
pub content_hash: Option<String>,
pub created_at_ms: Option<i64>,
}
impl ReadingMaterialRefV2 {
pub fn new(
material_id: impl Into<String>,
file_path: impl Into<String>,
file_name: impl Into<String>,
file_type: impl Into<String>,
) -> 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<String>) -> 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<String>) -> 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"));
}
}

View File

@ -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<HashMap<String, ReadingSessionV2>> {
@ -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<String, SessionError> {
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

View File

@ -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<String, String> {
fn start_reading_session_v2(material: ReadingMaterialRefV2, timestamp_ms: i64) -> Result<String, String> {
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();