1use crate::app::AppState;
17use crate::collaboration::signalr::{encode_message, HubMessage};
18use crate::entity::editor::EditorUnitInput;
19use crate::repository::editor_page_repo::EditorPageRepository;
20use crate::repository::editor_unit_repo::EditorUnitRepository;
21use dashmap::DashMap;
22use serde::{Deserialize, Serialize};
23use serde_json::{json, Value};
24use std::sync::atomic::{AtomicU64, Ordering};
25use std::sync::Arc;
26use std::time::Duration;
27use tokio::sync::mpsc;
28
29const LOCK_TTL_SECS: u64 = 120;
31const SNAPSHOT_TTL_SECS: u64 = 6 * 3600;
33const PRESENCE_TTL_SECS: u64 = 3600;
35const PERSIST_DEBOUNCE_MS: u64 = 1500;
37const FANOUT_CHANNEL: &str = "hub:fanout";
39
40#[derive(Clone, Debug, Default, Deserialize, Serialize)]
42#[serde(default)]
43pub struct PageEditorState {
44 pub user_id: String,
46 pub display_name: String,
48 pub avatar_url: Option<String>,
50 pub page_key: String,
52 pub page_index: i32,
54 pub page_name: String,
56 pub mode: String,
58 pub editor_state: String,
60 pub acquired_at: i64,
62}
63
64#[derive(Clone, Debug, Default, Deserialize, Serialize)]
66pub struct ProjectState {
67 pub project_key: String,
69 pub page_editors: Vec<PageEditorState>,
71}
72
73#[derive(Clone, Debug, Default, Deserialize, Serialize)]
75#[serde(default)]
76pub struct PageSnapshot {
77 pub project_key: String,
79 pub page_key: String,
81 pub page_index: i32,
83 pub page_name: String,
85 pub mode: String,
87 pub updated_by_user_id: String,
89 pub updated_by_display_name: String,
91 pub updated_at: i64,
93 pub units: Vec<Value>,
95}
96
97#[derive(Clone, Debug, Serialize)]
99pub struct TryAcquireResult {
100 pub acquired: bool,
102 pub editor: Option<PageEditorState>,
104}
105
106#[derive(Clone, Debug, Default, Deserialize)]
108#[serde(default)]
109struct JoinProjectArgs {
110 project_key: String,
112 display_name: String,
114 avatar_url: Option<String>,
116}
117
118#[derive(Clone, Debug, Default, Deserialize)]
120#[serde(default)]
121struct OpenPageArgs {
122 project_key: String,
124 page_key: String,
126 page_index: i32,
128 page_name: String,
130 mode: String,
132 units: Vec<Value>,
134}
135
136#[derive(Clone, Debug, Default, Deserialize)]
138#[serde(default)]
139struct TryAcquireArgs {
140 project_key: String,
142 page_key: String,
144 page_index: i32,
146 page_name: String,
148 mode: String,
150}
151
152#[derive(Clone, Debug, Default, Deserialize)]
154#[serde(default)]
155struct UpdateModeArgs {
156 project_key: String,
158 page_key: String,
160 mode: String,
162}
163
164#[derive(Clone, Debug, Default, Deserialize)]
166#[serde(default)]
167struct ReleaseArgs {
168 project_key: String,
170 page_key: String,
172}
173
174#[derive(Clone, Debug, Deserialize, Serialize)]
176struct LockHolder {
177 connection_id: String,
179 editor: PageEditorState,
181}
182
183struct ConnState {
185 user_id: String,
187 display_name: String,
189 avatar_url: Option<String>,
191 project_key: Option<String>,
193 page_key: Option<String>,
195 lock_page_key: Option<String>,
197 outbound: mpsc::UnboundedSender<String>,
199}
200
201#[derive(Clone)]
203struct ConnIdentity {
204 user_id: String,
206 display_name: String,
208 avatar_url: Option<String>,
210 #[allow(dead_code)]
212 project_key: Option<String>,
213 lock_page_key: Option<String>,
215}
216
217pub struct CollaborationRegistry {
219 connections: DashMap<String, ConnState>,
221 persist_gen: DashMap<String, Arc<AtomicU64>>,
223}
224
225impl Default for CollaborationRegistry {
226 fn default() -> Self {
227 Self::new()
228 }
229}
230
231impl CollaborationRegistry {
232 pub fn new() -> Self {
234 Self {
235 connections: DashMap::new(),
236 persist_gen: DashMap::new(),
237 }
238 }
239
240 pub fn register(
242 &self,
243 connection_id: String,
244 user_id: String,
245 ) -> mpsc::UnboundedReceiver<String> {
246 let (tx, rx) = mpsc::unbounded_channel();
247 self.connections.insert(
248 connection_id,
249 ConnState {
250 user_id,
251 display_name: String::new(),
252 avatar_url: None,
253 project_key: None,
254 page_key: None,
255 lock_page_key: None,
256 outbound: tx,
257 },
258 );
259 rx
260 }
261
262 pub fn sender(&self, connection_id: &str) -> Option<mpsc::UnboundedSender<String>> {
264 self.connections
265 .get(connection_id)
266 .map(|c| c.outbound.clone())
267 }
268
269 fn identity(&self, connection_id: &str) -> Option<ConnIdentity> {
271 self.connections.get(connection_id).map(|c| ConnIdentity {
272 user_id: c.user_id.clone(),
273 display_name: c.display_name.clone(),
274 avatar_url: c.avatar_url.clone(),
275 project_key: c.project_key.clone(),
276 lock_page_key: c.lock_page_key.clone(),
277 })
278 }
279
280 fn local_broadcast(&self, project_key: &str, frame: &str, exclude: Option<&str>) {
282 for entry in self.connections.iter() {
283 if Some(entry.key().as_str()) == exclude {
284 continue;
285 }
286 if entry.value().project_key.as_deref() == Some(project_key) {
287 let _ = entry.value().outbound.send(frame.to_string());
288 }
289 }
290 }
291}
292
293fn now_ms() -> i64 {
295 chrono::Utc::now().timestamp_millis()
296}
297
298fn presence_key(project: &str) -> String {
300 format!("hub:{project}:presence")
301}
302
303fn lock_key(project: &str, page: &str) -> String {
305 format!("hub:{project}:lock:{page}")
306}
307
308fn snapshot_key(project: &str, page: &str) -> String {
310 format!("hub:{project}:snapshot:{page}")
311}
312
313fn parse_episode_id(project_key: &str) -> Option<i32> {
315 project_key
316 .rsplit(':')
317 .next()
318 .and_then(|s| s.trim().parse().ok())
319}
320
321fn parse_page_id(page_key: &str) -> Option<i64> {
323 page_key
324 .rsplit(':')
325 .next()
326 .and_then(|s| s.trim().parse().ok())
327}
328
329fn normalize_mode(mode: &str) -> String {
331 if mode == "proofread" {
332 "proofread".to_string()
333 } else {
334 "translate".to_string()
335 }
336}
337
338pub fn spawn_fanout(state: AppState) {
340 let redis = state.redis.clone();
341 let registry = state.collaboration.clone();
342 let self_id = redis.sender_id().to_string();
343 tokio::spawn(async move {
344 redis
345 .subscribe_loop(FANOUT_CHANNEL, move |payload| {
346 let Ok(v) = serde_json::from_str::<Value>(&payload) else {
347 return;
348 };
349 if v.get("sender").and_then(Value::as_str) == Some(self_id.as_str()) {
351 return;
352 }
353 let project = v.get("project").and_then(Value::as_str).unwrap_or("");
354 let frame = v.get("frame").and_then(Value::as_str).unwrap_or("");
355 if !project.is_empty() && !frame.is_empty() {
356 registry.local_broadcast(project, frame, None);
357 }
358 })
359 .await;
360 });
361}
362
363async fn broadcast(
365 state: &AppState,
366 project: &str,
367 target: &str,
368 payload: Value,
369 exclude: Option<&str>,
370) {
371 let Ok(frame) = encode_message(&HubMessage::event(target, payload)) else {
372 return;
373 };
374 state
375 .collaboration
376 .local_broadcast(project, &frame, exclude);
377 let envelope = json!({
378 "sender": state.redis.sender_id(),
379 "project": project,
380 "frame": frame,
381 });
382 state
383 .redis
384 .publish(FANOUT_CHANNEL, &envelope.to_string())
385 .await;
386}
387
388async fn get_project_state(state: &AppState, project: &str) -> ProjectState {
390 let entries = state.redis.hgetall(&presence_key(project)).await;
391 let page_editors = entries
392 .into_iter()
393 .filter_map(|(_, v)| serde_json::from_str::<PageEditorState>(&v).ok())
394 .collect();
395 ProjectState {
396 project_key: project.to_string(),
397 page_editors,
398 }
399}
400
401async fn upsert_presence(state: &AppState, project: &str, conn_id: &str, editor: &PageEditorState) {
403 if let Ok(json) = serde_json::to_string(editor) {
404 state
405 .redis
406 .hset_ttl(&presence_key(project), conn_id, &json, PRESENCE_TTL_SECS)
407 .await;
408 }
409}
410
411async fn get_presence(state: &AppState, project: &str, conn_id: &str) -> Option<PageEditorState> {
413 state
414 .redis
415 .hgetall(&presence_key(project))
416 .await
417 .into_iter()
418 .find(|(field, _)| field == conn_id)
419 .and_then(|(_, v)| serde_json::from_str::<PageEditorState>(&v).ok())
420}
421
422async fn demote_other_holders(state: &AppState, project: &str, page: &str, keep_conn: &str) {
424 for (field, v) in state.redis.hgetall(&presence_key(project)).await {
425 if field == keep_conn {
426 continue;
427 }
428 if let Ok(mut ed) = serde_json::from_str::<PageEditorState>(&v) {
429 if ed.page_key == page && ed.editor_state == "lock_holder" {
430 ed.editor_state = "viewing".to_string();
431 upsert_presence(state, project, &field, &ed).await;
432 }
433 }
434 }
435}
436
437async fn broadcast_project_state(state: &AppState, project: &str) {
439 let st = get_project_state(state, project).await;
440 broadcast(state, project, "ProjectStateUpdated", json!(st), None).await;
441}
442
443pub async fn handle_invocation(
450 state: &AppState,
451 conn_id: &str,
452 target: &str,
453 args: Vec<Value>,
454) -> Result<Option<Value>, String> {
455 let arg0 = args.into_iter().next().unwrap_or(Value::Null);
456 match target {
457 "JoinProject" => join_project(state, conn_id, arg0).await.map(Some),
458 "OpenPage" => open_page(state, conn_id, arg0).await.map(Some),
459 "TryAcquirePageLock" => try_acquire_lock(state, conn_id, arg0).await.map(Some),
460 "UpdatePageLockMode" => {
461 update_lock_mode(state, conn_id, arg0).await?;
462 Ok(None)
463 }
464 "ReleaseCurrentPageLock" => {
465 release_lock(state, conn_id, arg0).await?;
466 Ok(None)
467 }
468 "SyncPageSnapshot" => {
469 sync_snapshot(state, conn_id, arg0).await?;
470 Ok(None)
471 }
472 "SyncPageLiveDelta" => {
473 sync_live_delta(state, conn_id, arg0).await?;
474 Ok(None)
475 }
476 other => Err(format!("不支持的协作方法:{other}")),
477 }
478}
479
480pub async fn disconnect(state: &AppState, conn_id: &str) {
482 let info = state
483 .collaboration
484 .connections
485 .get(conn_id)
486 .map(|c| (c.project_key.clone(), c.lock_page_key.clone()));
487 state.collaboration.connections.remove(conn_id);
488 if let Some((Some(project), lock_page)) = info {
489 cleanup_in_project(state, conn_id, &project, lock_page).await;
490 }
491}
492
493async fn cleanup_in_project(
495 state: &AppState,
496 conn_id: &str,
497 project: &str,
498 lock_page: Option<String>,
499) {
500 if let Some(page) = lock_page {
501 state.redis.del(&lock_key(project, &page)).await;
502 }
503 state.redis.hdel(&presence_key(project), conn_id).await;
504 broadcast_project_state(state, project).await;
505}
506
507async fn join_project(state: &AppState, conn_id: &str, arg: Value) -> Result<Value, String> {
509 let req: JoinProjectArgs = serde_json::from_value(arg).map_err(|e| e.to_string())?;
510 let project = req.project_key.trim().to_string();
511 if project.is_empty() {
512 return Err("缺少 project_key".to_string());
513 }
514 let display = if req.display_name.trim().is_empty() {
515 "协作成员".to_string()
516 } else {
517 req.display_name.trim().to_string()
518 };
519
520 let (old_project, old_lock) = {
522 let mut c = state
523 .collaboration
524 .connections
525 .get_mut(conn_id)
526 .ok_or("连接不存在")?;
527 let old_project = c.project_key.clone();
528 let old_lock = c.lock_page_key.clone();
529 c.display_name = display;
530 c.avatar_url = req.avatar_url.clone();
531 if old_project.as_deref() != Some(project.as_str()) {
532 c.project_key = Some(project.clone());
533 c.page_key = None;
534 c.lock_page_key = None;
535 }
536 (old_project, old_lock)
537 };
538
539 if let Some(op) = old_project {
541 if op != project {
542 cleanup_in_project(state, conn_id, &op, old_lock).await;
543 }
544 }
545
546 let st = get_project_state(state, &project).await;
547 Ok(json!(st))
548}
549
550async fn open_page(state: &AppState, conn_id: &str, arg: Value) -> Result<Value, String> {
552 let req: OpenPageArgs = serde_json::from_value(arg).map_err(|e| e.to_string())?;
553 let ident = state.collaboration.identity(conn_id).ok_or("连接不存在")?;
554 let project = req.project_key.trim().to_string();
555 if project.is_empty() || req.page_key.is_empty() {
556 return Err("缺少 project_key 或 page_key".to_string());
557 }
558
559 {
561 if let Some(mut c) = state.collaboration.connections.get_mut(conn_id) {
562 c.page_key = Some(req.page_key.clone());
563 }
564 }
565
566 let holds_lock = ident.lock_page_key.as_deref() == Some(req.page_key.as_str());
567 let editor = PageEditorState {
568 user_id: ident.user_id.clone(),
569 display_name: ident.display_name.clone(),
570 avatar_url: ident.avatar_url.clone(),
571 page_key: req.page_key.clone(),
572 page_index: req.page_index,
573 page_name: req.page_name.clone(),
574 mode: normalize_mode(&req.mode),
575 editor_state: if holds_lock { "lock_holder" } else { "viewing" }.to_string(),
576 acquired_at: now_ms(),
577 };
578 upsert_presence(state, &project, conn_id, &editor).await;
579 broadcast_project_state(state, &project).await;
580
581 if let Some(existing) = state
583 .redis
584 .get_str(&snapshot_key(&project, &req.page_key))
585 .await
586 {
587 if let Ok(v) = serde_json::from_str::<Value>(&existing) {
588 return Ok(v);
589 }
590 }
591 if req.units.is_empty() {
592 return Ok(Value::Null);
593 }
594 let snapshot = PageSnapshot {
595 project_key: project.clone(),
596 page_key: req.page_key.clone(),
597 page_index: req.page_index,
598 page_name: req.page_name.clone(),
599 mode: normalize_mode(&req.mode),
600 updated_by_user_id: ident.user_id.clone(),
601 updated_by_display_name: ident.display_name.clone(),
602 updated_at: now_ms(),
603 units: req.units.clone(),
604 };
605 if let Ok(json) = serde_json::to_string(&snapshot) {
606 state
607 .redis
608 .set_str_ttl(
609 &snapshot_key(&project, &req.page_key),
610 &json,
611 SNAPSHOT_TTL_SECS,
612 )
613 .await;
614 }
615 Ok(json!(snapshot))
616}
617
618async fn try_acquire_lock(state: &AppState, conn_id: &str, arg: Value) -> Result<Value, String> {
620 let req: TryAcquireArgs = serde_json::from_value(arg).map_err(|e| e.to_string())?;
621 let ident = state.collaboration.identity(conn_id).ok_or("连接不存在")?;
622 let project = req.project_key.trim().to_string();
623 if project.is_empty() || req.page_key.is_empty() {
624 return Err("缺少 project_key 或 page_key".to_string());
625 }
626
627 let editor = PageEditorState {
628 user_id: ident.user_id.clone(),
629 display_name: ident.display_name.clone(),
630 avatar_url: ident.avatar_url.clone(),
631 page_key: req.page_key.clone(),
632 page_index: req.page_index,
633 page_name: req.page_name.clone(),
634 mode: normalize_mode(&req.mode),
635 editor_state: "lock_holder".to_string(),
636 acquired_at: now_ms(),
637 };
638 let holder = LockHolder {
639 connection_id: conn_id.to_string(),
640 editor: editor.clone(),
641 };
642 let holder_json = serde_json::to_string(&holder).map_err(|e| e.to_string())?;
643 let lk = lock_key(&project, &req.page_key);
644
645 let acquired = if state
646 .redis
647 .set_nx_ex(&lk, &holder_json, LOCK_TTL_SECS)
648 .await
649 {
650 true
651 } else {
652 match state
653 .redis
654 .get_str(&lk)
655 .await
656 .and_then(|v| serde_json::from_str::<LockHolder>(&v).ok())
657 {
658 Some(cur) if cur.connection_id == conn_id => {
660 state
661 .redis
662 .set_str_ttl(&lk, &holder_json, LOCK_TTL_SECS)
663 .await;
664 true
665 }
666 Some(cur) => {
668 return Ok(json!(TryAcquireResult {
669 acquired: false,
670 editor: Some(cur.editor),
671 }));
672 }
673 None => {
675 state
676 .redis
677 .set_str_ttl(&lk, &holder_json, LOCK_TTL_SECS)
678 .await;
679 true
680 }
681 }
682 };
683
684 if acquired {
685 if let Some(old) = ident.lock_page_key.as_ref() {
687 if old != &req.page_key {
688 state.redis.del(&lock_key(&project, old)).await;
689 }
690 }
691 if let Some(mut c) = state.collaboration.connections.get_mut(conn_id) {
692 c.lock_page_key = Some(req.page_key.clone());
693 c.page_key = Some(req.page_key.clone());
694 }
695 demote_other_holders(state, &project, &req.page_key, conn_id).await;
696 upsert_presence(state, &project, conn_id, &editor).await;
697 broadcast_project_state(state, &project).await;
698 }
699
700 Ok(json!(TryAcquireResult {
701 acquired,
702 editor: Some(editor),
703 }))
704}
705
706async fn update_lock_mode(state: &AppState, conn_id: &str, arg: Value) -> Result<(), String> {
708 let req: UpdateModeArgs = serde_json::from_value(arg).map_err(|e| e.to_string())?;
709 let ident = state.collaboration.identity(conn_id).ok_or("连接不存在")?;
710 let project = req.project_key.trim().to_string();
711 if ident.lock_page_key.as_deref() != Some(req.page_key.as_str()) {
712 return Ok(());
713 }
714 if let Some(mut editor) = get_presence(state, &project, conn_id).await {
715 editor.mode = normalize_mode(&req.mode);
716 editor.editor_state = "lock_holder".to_string();
717 upsert_presence(state, &project, conn_id, &editor).await;
718 }
719 state
720 .redis
721 .expire(&lock_key(&project, &req.page_key), LOCK_TTL_SECS)
722 .await;
723 broadcast_project_state(state, &project).await;
724 Ok(())
725}
726
727async fn release_lock(state: &AppState, conn_id: &str, arg: Value) -> Result<(), String> {
729 let req: ReleaseArgs = serde_json::from_value(arg).map_err(|e| e.to_string())?;
730 let ident = state.collaboration.identity(conn_id).ok_or("连接不存在")?;
731 let project = req.project_key.trim().to_string();
732 if ident.lock_page_key.as_deref() != Some(req.page_key.as_str()) {
733 return Ok(());
734 }
735 state.redis.del(&lock_key(&project, &req.page_key)).await;
736 if let Some(mut c) = state.collaboration.connections.get_mut(conn_id) {
737 c.lock_page_key = None;
738 }
739 if let Some(mut editor) = get_presence(state, &project, conn_id).await {
740 editor.editor_state = "viewing".to_string();
741 upsert_presence(state, &project, conn_id, &editor).await;
742 }
743 broadcast_project_state(state, &project).await;
744 Ok(())
745}
746
747async fn sync_snapshot(state: &AppState, conn_id: &str, arg: Value) -> Result<(), String> {
749 let req: OpenPageArgs = serde_json::from_value(arg).map_err(|e| e.to_string())?;
750 let ident = state.collaboration.identity(conn_id).ok_or("连接不存在")?;
751 let project = req.project_key.trim().to_string();
752 if project.is_empty() || req.page_key.is_empty() {
753 return Err("缺少 project_key 或 page_key".to_string());
754 }
755 let snapshot = PageSnapshot {
756 project_key: project.clone(),
757 page_key: req.page_key.clone(),
758 page_index: req.page_index,
759 page_name: req.page_name.clone(),
760 mode: normalize_mode(&req.mode),
761 updated_by_user_id: ident.user_id.clone(),
762 updated_by_display_name: ident.display_name.clone(),
763 updated_at: now_ms(),
764 units: req.units.clone(),
765 };
766 if let Ok(json) = serde_json::to_string(&snapshot) {
767 state
768 .redis
769 .set_str_ttl(
770 &snapshot_key(&project, &req.page_key),
771 &json,
772 SNAPSHOT_TTL_SECS,
773 )
774 .await;
775 }
776 if ident.lock_page_key.as_deref() == Some(req.page_key.as_str()) {
778 state
779 .redis
780 .expire(&lock_key(&project, &req.page_key), LOCK_TTL_SECS)
781 .await;
782 }
783 broadcast(
784 state,
785 &project,
786 "PageSnapshotUpdated",
787 json!(snapshot),
788 Some(conn_id),
789 )
790 .await;
791 schedule_persist(state, &project, &req.page_key, req.units, &ident.user_id);
792 Ok(())
793}
794
795async fn sync_live_delta(state: &AppState, conn_id: &str, arg: Value) -> Result<(), String> {
797 let project = arg
798 .get("project_key")
799 .and_then(Value::as_str)
800 .unwrap_or("")
801 .to_string();
802 if project.is_empty() {
803 return Ok(());
804 }
805 broadcast(state, &project, "PageLiveDeltaUpdated", arg, Some(conn_id)).await;
806 Ok(())
807}
808
809fn schedule_persist(
811 state: &AppState,
812 project: &str,
813 page_key: &str,
814 units: Vec<Value>,
815 user_id: &str,
816) {
817 let gen = state
818 .collaboration
819 .persist_gen
820 .entry(page_key.to_string())
821 .or_insert_with(|| Arc::new(AtomicU64::new(0)))
822 .clone();
823 let target = gen.fetch_add(1, Ordering::SeqCst) + 1;
824
825 let state = state.clone();
826 let project = project.to_string();
827 let page_key = page_key.to_string();
828 let user_id = user_id.to_string();
829 tokio::spawn(async move {
830 tokio::time::sleep(Duration::from_millis(PERSIST_DEBOUNCE_MS)).await;
831 if gen.load(Ordering::SeqCst) != target {
833 return;
834 }
835 persist_snapshot(&state, &project, &page_key, &units, &user_id).await;
836 });
837}
838
839async fn persist_snapshot(
841 state: &AppState,
842 project: &str,
843 page_key: &str,
844 units: &[Value],
845 user_id: &str,
846) {
847 let Some(page_id) = parse_page_id(page_key) else {
848 return;
849 };
850 let member_id = user_id.parse::<i32>().unwrap_or(0);
851 if member_id <= 0 {
852 return;
853 }
854 let episode_id = match parse_episode_id(project) {
856 Some(e) => e,
857 None => match EditorPageRepository::new(state.db.clone())
858 .episode_of_page(page_id)
859 .await
860 {
861 Ok(Some(e)) => e,
862 _ => return,
863 },
864 };
865 let inputs: Vec<EditorUnitInput> = units
866 .iter()
867 .filter_map(|v| serde_json::from_value::<EditorUnitInput>(v.clone()).ok())
868 .collect();
869 if let Err(e) = EditorUnitRepository::new(state.db.clone())
870 .replace_page_units(episode_id, page_id, member_id, &inputs)
871 .await
872 {
873 tracing::warn!("协作快照落库失败 page={page_id}: {e:?}");
874 }
875}