mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +08:00
实现本地与远程数据库自动同步及冲突选择保留策略
This commit is contained in:
@@ -2,14 +2,16 @@ use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use crate::db::connection::{create_postgres_connection, create_sqlite_connection};
|
||||
use crate::db::connection::DatabaseType;
|
||||
use crate::db::entities::{reasons, score_events, student_tags, students, tags};
|
||||
use crate::db::migration::run_migration;
|
||||
use crate::services::permission::PermissionLevel;
|
||||
use crate::services::settings::{SettingsKey, SettingsValue};
|
||||
use crate::state::AppState;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
@@ -42,12 +44,550 @@ pub struct SyncResult {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DbSyncConflict {
|
||||
pub table: String,
|
||||
pub key: String,
|
||||
pub local_summary: String,
|
||||
pub remote_summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DbSyncPreviewResult {
|
||||
pub can_sync: bool,
|
||||
pub need_sync: bool,
|
||||
pub local_only: usize,
|
||||
pub remote_only: usize,
|
||||
pub conflicts: Vec<DbSyncConflict>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DbSyncApplyResult {
|
||||
pub success: bool,
|
||||
pub synced_records: usize,
|
||||
pub resolved_conflicts: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SettingChange {
|
||||
pub key: String,
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConflictStrategy {
|
||||
KeepLocal,
|
||||
KeepRemote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct StudentNormalized {
|
||||
name: String,
|
||||
score: i32,
|
||||
tags: String,
|
||||
extra_json: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ReasonNormalized {
|
||||
content: String,
|
||||
category: String,
|
||||
delta: i32,
|
||||
is_system: i32,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct TagNormalized {
|
||||
name: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct EventNormalized {
|
||||
uuid: String,
|
||||
student_name: String,
|
||||
reason_content: String,
|
||||
delta: i32,
|
||||
val_prev: i32,
|
||||
val_curr: i32,
|
||||
event_time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct StudentTagPair {
|
||||
student_name: String,
|
||||
tag_name: String,
|
||||
}
|
||||
|
||||
fn normalize_tags(raw: &str) -> String {
|
||||
let parsed: Vec<String> = serde_json::from_str(raw).unwrap_or_default();
|
||||
let mut cleaned: Vec<String> = parsed
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
cleaned.sort();
|
||||
cleaned.dedup();
|
||||
serde_json::to_string(&cleaned).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
fn sqlite_db_path(app_handle: &AppHandle) -> Result<String, String> {
|
||||
if cfg!(all(debug_assertions, desktop)) {
|
||||
return Ok("data.sql".to_string());
|
||||
}
|
||||
|
||||
let app_data_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Failed to get app data directory: {}", e))?;
|
||||
let data_dir = app_data_dir.join("data");
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
let db_path = data_dir.join("data.sql");
|
||||
db_path
|
||||
.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Invalid sqlite database path".to_string())
|
||||
}
|
||||
|
||||
async fn load_students(
|
||||
conn: &sea_orm::DatabaseConnection,
|
||||
) -> Result<std::collections::HashMap<String, StudentNormalized>, String> {
|
||||
let rows = students::Entity::find()
|
||||
.all(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
map.insert(
|
||||
row.name.clone(),
|
||||
StudentNormalized {
|
||||
name: row.name,
|
||||
score: row.score,
|
||||
tags: normalize_tags(&row.tags),
|
||||
extra_json: row.extra_json,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
async fn load_reasons(
|
||||
conn: &sea_orm::DatabaseConnection,
|
||||
) -> Result<std::collections::HashMap<String, ReasonNormalized>, String> {
|
||||
let rows = reasons::Entity::find()
|
||||
.all(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
map.insert(
|
||||
row.content.clone(),
|
||||
ReasonNormalized {
|
||||
content: row.content,
|
||||
category: row.category,
|
||||
delta: row.delta,
|
||||
is_system: row.is_system,
|
||||
updated_at: row.updated_at,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
async fn load_tags(
|
||||
conn: &sea_orm::DatabaseConnection,
|
||||
) -> Result<std::collections::HashMap<String, TagNormalized>, String> {
|
||||
let rows = tags::Entity::find()
|
||||
.all(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
map.insert(
|
||||
row.name.clone(),
|
||||
TagNormalized {
|
||||
name: row.name,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
async fn load_events(
|
||||
conn: &sea_orm::DatabaseConnection,
|
||||
) -> Result<std::collections::HashMap<String, EventNormalized>, String> {
|
||||
let rows = score_events::Entity::find()
|
||||
.all(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
map.insert(
|
||||
row.uuid.clone(),
|
||||
EventNormalized {
|
||||
uuid: row.uuid,
|
||||
student_name: row.student_name,
|
||||
reason_content: row.reason_content,
|
||||
delta: row.delta,
|
||||
val_prev: row.val_prev,
|
||||
val_curr: row.val_curr,
|
||||
event_time: row.event_time,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
async fn load_student_tag_pairs(
|
||||
conn: &sea_orm::DatabaseConnection,
|
||||
) -> Result<std::collections::HashSet<StudentTagPair>, String> {
|
||||
let student_rows = students::Entity::find()
|
||||
.all(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag_rows = tags::Entity::find().all(conn).await.map_err(|e| e.to_string())?;
|
||||
let link_rows = student_tags::Entity::find()
|
||||
.all(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let student_name_map: std::collections::HashMap<i32, String> = student_rows
|
||||
.into_iter()
|
||||
.map(|s| (s.id, s.name))
|
||||
.collect();
|
||||
let tag_name_map: std::collections::HashMap<i32, String> =
|
||||
tag_rows.into_iter().map(|t| (t.id, t.name)).collect();
|
||||
|
||||
let mut pairs = std::collections::HashSet::new();
|
||||
for rel in link_rows {
|
||||
if let (Some(student_name), Some(tag_name)) = (
|
||||
student_name_map.get(&rel.student_id),
|
||||
tag_name_map.get(&rel.tag_id),
|
||||
) {
|
||||
pairs.insert(StudentTagPair {
|
||||
student_name: student_name.clone(),
|
||||
tag_name: tag_name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(pairs)
|
||||
}
|
||||
|
||||
fn compare_maps<T: PartialEq>(
|
||||
table: &str,
|
||||
local: &std::collections::HashMap<String, T>,
|
||||
remote: &std::collections::HashMap<String, T>,
|
||||
) -> (usize, usize, Vec<(String, String)>) {
|
||||
let mut local_only = 0usize;
|
||||
let mut remote_only = 0usize;
|
||||
let mut conflicts: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for (key, local_value) in local {
|
||||
if let Some(remote_value) = remote.get(key) {
|
||||
if local_value != remote_value {
|
||||
conflicts.push((table.to_string(), key.clone()));
|
||||
}
|
||||
} else {
|
||||
local_only += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for key in remote.keys() {
|
||||
if !local.contains_key(key) {
|
||||
remote_only += 1;
|
||||
}
|
||||
}
|
||||
|
||||
(local_only, remote_only, conflicts)
|
||||
}
|
||||
|
||||
async fn upsert_student(
|
||||
conn: &sea_orm::DatabaseConnection,
|
||||
data: &StudentNormalized,
|
||||
) -> Result<bool, String> {
|
||||
let existing = students::Entity::find()
|
||||
.filter(students::Column::Name.eq(&data.name))
|
||||
.one(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match existing {
|
||||
Some(row) => {
|
||||
let normalized_current = StudentNormalized {
|
||||
name: row.name.clone(),
|
||||
score: row.score,
|
||||
tags: normalize_tags(&row.tags),
|
||||
extra_json: row.extra_json.clone(),
|
||||
created_at: row.created_at.clone(),
|
||||
updated_at: row.updated_at.clone(),
|
||||
};
|
||||
if normalized_current == *data {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut active: students::ActiveModel = row.into();
|
||||
active.score = Set(data.score);
|
||||
active.tags = Set(data.tags.clone());
|
||||
active.extra_json = Set(data.extra_json.clone());
|
||||
active.created_at = Set(data.created_at.clone());
|
||||
active.updated_at = Set(data.updated_at.clone());
|
||||
active.update(conn).await.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
None => {
|
||||
students::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
name: Set(data.name.clone()),
|
||||
score: Set(data.score),
|
||||
tags: Set(data.tags.clone()),
|
||||
extra_json: Set(data.extra_json.clone()),
|
||||
created_at: Set(data.created_at.clone()),
|
||||
updated_at: Set(data.updated_at.clone()),
|
||||
}
|
||||
.insert(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn upsert_reason(
|
||||
conn: &sea_orm::DatabaseConnection,
|
||||
data: &ReasonNormalized,
|
||||
) -> Result<bool, String> {
|
||||
let existing = reasons::Entity::find()
|
||||
.filter(reasons::Column::Content.eq(&data.content))
|
||||
.one(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
match existing {
|
||||
Some(row) => {
|
||||
let normalized_current = ReasonNormalized {
|
||||
content: row.content.clone(),
|
||||
category: row.category.clone(),
|
||||
delta: row.delta,
|
||||
is_system: row.is_system,
|
||||
updated_at: row.updated_at.clone(),
|
||||
};
|
||||
if normalized_current == *data {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut active: reasons::ActiveModel = row.into();
|
||||
active.category = Set(data.category.clone());
|
||||
active.delta = Set(data.delta);
|
||||
active.is_system = Set(data.is_system);
|
||||
active.updated_at = Set(data.updated_at.clone());
|
||||
active.update(conn).await.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
None => {
|
||||
reasons::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
content: Set(data.content.clone()),
|
||||
category: Set(data.category.clone()),
|
||||
delta: Set(data.delta),
|
||||
is_system: Set(data.is_system),
|
||||
updated_at: Set(data.updated_at.clone()),
|
||||
}
|
||||
.insert(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn upsert_tag(conn: &sea_orm::DatabaseConnection, data: &TagNormalized) -> Result<bool, String> {
|
||||
let existing = tags::Entity::find()
|
||||
.filter(tags::Column::Name.eq(&data.name))
|
||||
.one(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
match existing {
|
||||
Some(row) => {
|
||||
let normalized_current = TagNormalized {
|
||||
name: row.name.clone(),
|
||||
created_at: row.created_at.clone(),
|
||||
updated_at: row.updated_at.clone(),
|
||||
};
|
||||
if normalized_current == *data {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut active: tags::ActiveModel = row.into();
|
||||
active.created_at = Set(data.created_at.clone());
|
||||
active.updated_at = Set(data.updated_at.clone());
|
||||
active.update(conn).await.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
None => {
|
||||
tags::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
name: Set(data.name.clone()),
|
||||
created_at: Set(data.created_at.clone()),
|
||||
updated_at: Set(data.updated_at.clone()),
|
||||
}
|
||||
.insert(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn upsert_event(
|
||||
conn: &sea_orm::DatabaseConnection,
|
||||
data: &EventNormalized,
|
||||
) -> Result<bool, String> {
|
||||
let existing = score_events::Entity::find()
|
||||
.filter(score_events::Column::Uuid.eq(&data.uuid))
|
||||
.one(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
match existing {
|
||||
Some(row) => {
|
||||
let normalized_current = EventNormalized {
|
||||
uuid: row.uuid.clone(),
|
||||
student_name: row.student_name.clone(),
|
||||
reason_content: row.reason_content.clone(),
|
||||
delta: row.delta,
|
||||
val_prev: row.val_prev,
|
||||
val_curr: row.val_curr,
|
||||
event_time: row.event_time.clone(),
|
||||
};
|
||||
if normalized_current == *data {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut active: score_events::ActiveModel = row.into();
|
||||
active.student_name = Set(data.student_name.clone());
|
||||
active.reason_content = Set(data.reason_content.clone());
|
||||
active.delta = Set(data.delta);
|
||||
active.val_prev = Set(data.val_prev);
|
||||
active.val_curr = Set(data.val_curr);
|
||||
active.event_time = Set(data.event_time.clone());
|
||||
active.update(conn).await.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
None => {
|
||||
score_events::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
uuid: Set(data.uuid.clone()),
|
||||
student_name: Set(data.student_name.clone()),
|
||||
reason_content: Set(data.reason_content.clone()),
|
||||
delta: Set(data.delta),
|
||||
val_prev: Set(data.val_prev),
|
||||
val_curr: Set(data.val_curr),
|
||||
event_time: Set(data.event_time.clone()),
|
||||
settlement_id: Set(None),
|
||||
}
|
||||
.insert(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_student_tag_pair(
|
||||
conn: &sea_orm::DatabaseConnection,
|
||||
pair: &StudentTagPair,
|
||||
) -> Result<bool, String> {
|
||||
let student_row = students::Entity::find()
|
||||
.filter(students::Column::Name.eq(&pair.student_name))
|
||||
.one(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag_row = tags::Entity::find()
|
||||
.filter(tags::Column::Name.eq(&pair.tag_name))
|
||||
.one(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (Some(student_row), Some(tag_row)) = (student_row, tag_row) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let existing = student_tags::Entity::find()
|
||||
.filter(student_tags::Column::StudentId.eq(student_row.id))
|
||||
.filter(student_tags::Column::TagId.eq(tag_row.id))
|
||||
.one(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
student_tags::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
student_id: Set(student_row.id),
|
||||
tag_id: Set(tag_row.id),
|
||||
created_at: Set(
|
||||
chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
.insert(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn current_remote_and_local(
|
||||
app_handle: &AppHandle,
|
||||
state: &State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<
|
||||
Option<(sea_orm::DatabaseConnection, sea_orm::DatabaseConnection)>,
|
||||
String,
|
||||
> {
|
||||
let (current_conn, db_type) = {
|
||||
let state_guard = state.read();
|
||||
let db = state_guard.db.read().clone();
|
||||
let settings = state_guard.settings.read();
|
||||
let status_json = settings.get_value(SettingsKey::PgConnectionStatus);
|
||||
let db_type = match status_json {
|
||||
SettingsValue::Json(json) => json
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("sqlite")
|
||||
.to_string(),
|
||||
_ => "sqlite".to_string(),
|
||||
};
|
||||
(db, db_type)
|
||||
};
|
||||
|
||||
let Some(remote_conn) = current_conn else {
|
||||
return Ok(None);
|
||||
};
|
||||
if db_type != "postgresql" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let local_path = sqlite_db_path(app_handle)?;
|
||||
let local_conn = create_sqlite_connection(&local_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
run_migration(&local_conn, DatabaseType::SQLite)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Some((local_conn, remote_conn)))
|
||||
}
|
||||
|
||||
fn check_admin_permission(state: &Arc<RwLock<AppState>>) -> Result<(), String> {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
@@ -240,6 +780,243 @@ pub async fn db_get_status(
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_sync_preview(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<DbSyncPreviewResult>, String> {
|
||||
check_admin_permission(&state)?;
|
||||
|
||||
let Some((local_conn, remote_conn)) = current_remote_and_local(&app_handle, &state).await? else {
|
||||
return Ok(IpcResponse::success(DbSyncPreviewResult {
|
||||
can_sync: false,
|
||||
need_sync: false,
|
||||
local_only: 0,
|
||||
remote_only: 0,
|
||||
conflicts: vec![],
|
||||
message: Some("当前不在 PostgreSQL 远程模式,已跳过同步预检查".to_string()),
|
||||
}));
|
||||
};
|
||||
|
||||
let local_students = load_students(&local_conn).await?;
|
||||
let remote_students = load_students(&remote_conn).await?;
|
||||
let local_reasons = load_reasons(&local_conn).await?;
|
||||
let remote_reasons = load_reasons(&remote_conn).await?;
|
||||
let local_tags = load_tags(&local_conn).await?;
|
||||
let remote_tags = load_tags(&remote_conn).await?;
|
||||
let local_events = load_events(&local_conn).await?;
|
||||
let remote_events = load_events(&remote_conn).await?;
|
||||
let local_pairs = load_student_tag_pairs(&local_conn).await?;
|
||||
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
|
||||
|
||||
let (stu_local_only, stu_remote_only, stu_conflicts) =
|
||||
compare_maps("students", &local_students, &remote_students);
|
||||
let (rea_local_only, rea_remote_only, rea_conflicts) =
|
||||
compare_maps("reasons", &local_reasons, &remote_reasons);
|
||||
let (tag_local_only, tag_remote_only, tag_conflicts) =
|
||||
compare_maps("tags", &local_tags, &remote_tags);
|
||||
let (evt_local_only, evt_remote_only, evt_conflicts) =
|
||||
compare_maps("score_events", &local_events, &remote_events);
|
||||
|
||||
let mut local_only = stu_local_only + rea_local_only + tag_local_only + evt_local_only;
|
||||
let mut remote_only = stu_remote_only + rea_remote_only + tag_remote_only + evt_remote_only;
|
||||
|
||||
for pair in &local_pairs {
|
||||
if !remote_pairs.contains(pair) {
|
||||
local_only += 1;
|
||||
}
|
||||
}
|
||||
for pair in &remote_pairs {
|
||||
if !local_pairs.contains(pair) {
|
||||
remote_only += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut conflicts = Vec::new();
|
||||
for (table, key) in stu_conflicts {
|
||||
let local = local_students.get(&key).expect("local student exists");
|
||||
let remote = remote_students.get(&key).expect("remote student exists");
|
||||
conflicts.push(DbSyncConflict {
|
||||
table,
|
||||
key,
|
||||
local_summary: format!("score={}, updated_at={}", local.score, local.updated_at),
|
||||
remote_summary: format!("score={}, updated_at={}", remote.score, remote.updated_at),
|
||||
});
|
||||
}
|
||||
for (table, key) in rea_conflicts {
|
||||
let local = local_reasons.get(&key).expect("local reason exists");
|
||||
let remote = remote_reasons.get(&key).expect("remote reason exists");
|
||||
conflicts.push(DbSyncConflict {
|
||||
table,
|
||||
key,
|
||||
local_summary: format!("delta={}, updated_at={}", local.delta, local.updated_at),
|
||||
remote_summary: format!("delta={}, updated_at={}", remote.delta, remote.updated_at),
|
||||
});
|
||||
}
|
||||
for (table, key) in tag_conflicts {
|
||||
let local = local_tags.get(&key).expect("local tag exists");
|
||||
let remote = remote_tags.get(&key).expect("remote tag exists");
|
||||
conflicts.push(DbSyncConflict {
|
||||
table,
|
||||
key,
|
||||
local_summary: format!("updated_at={}", local.updated_at),
|
||||
remote_summary: format!("updated_at={}", remote.updated_at),
|
||||
});
|
||||
}
|
||||
for (table, key) in evt_conflicts {
|
||||
let local = local_events.get(&key).expect("local event exists");
|
||||
let remote = remote_events.get(&key).expect("remote event exists");
|
||||
conflicts.push(DbSyncConflict {
|
||||
table,
|
||||
key,
|
||||
local_summary: format!(
|
||||
"delta={}, val_curr={}, event_time={}",
|
||||
local.delta, local.val_curr, local.event_time
|
||||
),
|
||||
remote_summary: format!(
|
||||
"delta={}, val_curr={}, event_time={}",
|
||||
remote.delta, remote.val_curr, remote.event_time
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let need_sync = local_only > 0 || remote_only > 0 || !conflicts.is_empty();
|
||||
Ok(IpcResponse::success(DbSyncPreviewResult {
|
||||
can_sync: true,
|
||||
need_sync,
|
||||
local_only,
|
||||
remote_only,
|
||||
conflicts,
|
||||
message: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_sync_apply(
|
||||
strategy: ConflictStrategy,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<DbSyncApplyResult>, String> {
|
||||
check_admin_permission(&state)?;
|
||||
|
||||
let Some((local_conn, remote_conn)) = current_remote_and_local(&app_handle, &state).await? else {
|
||||
return Ok(IpcResponse::success(DbSyncApplyResult {
|
||||
success: false,
|
||||
synced_records: 0,
|
||||
resolved_conflicts: 0,
|
||||
message: Some("当前不在 PostgreSQL 远程模式,无法执行同步".to_string()),
|
||||
}));
|
||||
};
|
||||
|
||||
let local_students = load_students(&local_conn).await?;
|
||||
let remote_students = load_students(&remote_conn).await?;
|
||||
let local_reasons = load_reasons(&local_conn).await?;
|
||||
let remote_reasons = load_reasons(&remote_conn).await?;
|
||||
let local_tags = load_tags(&local_conn).await?;
|
||||
let remote_tags = load_tags(&remote_conn).await?;
|
||||
let local_events = load_events(&local_conn).await?;
|
||||
let remote_events = load_events(&remote_conn).await?;
|
||||
let local_pairs = load_student_tag_pairs(&local_conn).await?;
|
||||
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
|
||||
|
||||
let (preferred, target) = if strategy == ConflictStrategy::KeepLocal {
|
||||
(&local_conn, &remote_conn)
|
||||
} else {
|
||||
(&remote_conn, &local_conn)
|
||||
};
|
||||
|
||||
let mut synced_records = 0usize;
|
||||
let mut resolved_conflicts = 0usize;
|
||||
|
||||
for student in local_students.values() {
|
||||
if upsert_student(&remote_conn, student).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for student in remote_students.values() {
|
||||
if upsert_student(&local_conn, student).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for reason in local_reasons.values() {
|
||||
if upsert_reason(&remote_conn, reason).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for reason in remote_reasons.values() {
|
||||
if upsert_reason(&local_conn, reason).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for tag in local_tags.values() {
|
||||
if upsert_tag(&remote_conn, tag).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for tag in remote_tags.values() {
|
||||
if upsert_tag(&local_conn, tag).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for event in local_events.values() {
|
||||
if upsert_event(&remote_conn, event).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for event in remote_events.values() {
|
||||
if upsert_event(&local_conn, event).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for pair in local_pairs.union(&remote_pairs) {
|
||||
if ensure_student_tag_pair(&local_conn, pair).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
if ensure_student_tag_pair(&remote_conn, pair).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let preferred_students = load_students(preferred).await?;
|
||||
for student in preferred_students.values() {
|
||||
if upsert_student(target, student).await? {
|
||||
resolved_conflicts += 1;
|
||||
}
|
||||
}
|
||||
let preferred_reasons = load_reasons(preferred).await?;
|
||||
for reason in preferred_reasons.values() {
|
||||
if upsert_reason(target, reason).await? {
|
||||
resolved_conflicts += 1;
|
||||
}
|
||||
}
|
||||
let preferred_tags = load_tags(preferred).await?;
|
||||
for tag in preferred_tags.values() {
|
||||
if upsert_tag(target, tag).await? {
|
||||
resolved_conflicts += 1;
|
||||
}
|
||||
}
|
||||
let preferred_events = load_events(preferred).await?;
|
||||
for event in preferred_events.values() {
|
||||
if upsert_event(target, event).await? {
|
||||
resolved_conflicts += 1;
|
||||
}
|
||||
}
|
||||
let preferred_pairs = load_student_tag_pairs(preferred).await?;
|
||||
for pair in &preferred_pairs {
|
||||
if ensure_student_tag_pair(target, pair).await? {
|
||||
resolved_conflicts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(IpcResponse::success(DbSyncApplyResult {
|
||||
success: true,
|
||||
synced_records,
|
||||
resolved_conflicts,
|
||||
message: Some("本地与远程同步完成".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_sync(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
|
||||
@@ -92,6 +92,8 @@ pub fn run() {
|
||||
db_switch_connection,
|
||||
db_get_status,
|
||||
db_sync,
|
||||
db_sync_preview,
|
||||
db_sync_apply,
|
||||
fs_get_config_structure,
|
||||
fs_read_json,
|
||||
fs_write_json,
|
||||
|
||||
+142
-1
@@ -1,5 +1,5 @@
|
||||
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from "antd"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Sidebar } from "./components/Sidebar"
|
||||
@@ -54,6 +54,13 @@ function MainContent(): React.JSX.Element {
|
||||
const [isPortraitMode, setIsPortraitMode] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
|
||||
const [syncConflictVisible, setSyncConflictVisible] = useState(false)
|
||||
const [syncConflicts, setSyncConflicts] = useState<
|
||||
Array<{ table: string; key: string; local_summary: string; remote_summary: string }>
|
||||
>([])
|
||||
const [syncApplyLoading, setSyncApplyLoading] = useState(false)
|
||||
const syncCheckingRef = useRef(false)
|
||||
const syncApplyLoadingRef = useRef(false)
|
||||
|
||||
const activeMenu = useMemo(() => {
|
||||
const p = location.pathname
|
||||
@@ -94,6 +101,85 @@ function MainContent(): React.JSX.Element {
|
||||
loadAuthAndSettings()
|
||||
}, [])
|
||||
|
||||
const applySyncStrategy = async (strategy: "keep_local" | "keep_remote") => {
|
||||
const api = (window as any).api
|
||||
if (!api) return
|
||||
setSyncApplyLoading(true)
|
||||
syncApplyLoadingRef.current = true
|
||||
try {
|
||||
const res = await api.dbSyncApply(strategy)
|
||||
if (res?.success && res?.data?.success) {
|
||||
messageApi.success(
|
||||
res.data.message || `同步完成(同步 ${res.data.synced_records} 条,解决冲突 ${res.data.resolved_conflicts} 条)`
|
||||
)
|
||||
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } }))
|
||||
} else {
|
||||
messageApi.error(res?.data?.message || res?.message || "同步失败")
|
||||
}
|
||||
} catch (error: any) {
|
||||
messageApi.error(error?.message || "同步失败")
|
||||
} finally {
|
||||
setSyncApplyLoading(false)
|
||||
syncApplyLoadingRef.current = false
|
||||
setSyncConflictVisible(false)
|
||||
setSyncConflicts([])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const api = (window as any).api
|
||||
if (!api) return
|
||||
let disposed = false
|
||||
|
||||
const checkAndSync = async () => {
|
||||
if (disposed || syncCheckingRef.current || syncApplyLoadingRef.current) return
|
||||
if (permission !== "admin") return
|
||||
try {
|
||||
syncCheckingRef.current = true
|
||||
const statusRes = await api.dbGetStatus()
|
||||
if (!statusRes?.success || statusRes?.data?.type !== "postgresql" || !statusRes?.data?.connected) {
|
||||
return
|
||||
}
|
||||
|
||||
const previewRes = await api.dbSyncPreview()
|
||||
if (!previewRes?.success || !previewRes?.data?.can_sync || !previewRes?.data?.need_sync) {
|
||||
return
|
||||
}
|
||||
|
||||
const conflicts = previewRes.data.conflicts || []
|
||||
if (conflicts.length > 0) {
|
||||
setSyncConflicts(conflicts)
|
||||
setSyncConflictVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
const applyRes = await api.dbSyncApply("keep_local")
|
||||
if (applyRes?.success && applyRes?.data?.success && applyRes?.data?.synced_records > 0) {
|
||||
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Auto sync failed:", error)
|
||||
} finally {
|
||||
syncCheckingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
checkAndSync()
|
||||
const timer = window.setInterval(checkAndSync, 30000)
|
||||
const onDataUpdated = () => {
|
||||
window.setTimeout(() => {
|
||||
checkAndSync().catch(() => void 0)
|
||||
}, 1200)
|
||||
}
|
||||
window.addEventListener("ss:data-updated", onDataUpdated)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
window.clearInterval(timer)
|
||||
window.removeEventListener("ss:data-updated", onDataUpdated)
|
||||
}
|
||||
}, [permission])
|
||||
|
||||
const login = async () => {
|
||||
if (!(window as any).api) return
|
||||
setAuthLoading(true)
|
||||
@@ -234,6 +320,61 @@ function MainContent(): React.JSX.Element {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="检测到本地与远程数据冲突"
|
||||
open={syncConflictVisible}
|
||||
onCancel={() => {
|
||||
if (syncApplyLoading) return
|
||||
setSyncConflictVisible(false)
|
||||
}}
|
||||
footer={null}
|
||||
closable={!syncApplyLoading}
|
||||
maskClosable={false}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ marginBottom: "10px", color: "var(--ss-text-secondary)", fontSize: "12px" }}>
|
||||
自动同步发现冲突,请选择冲突时优先保留哪一侧的数据。
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: "280px",
|
||||
overflow: "auto",
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
borderRadius: "6px",
|
||||
padding: "8px",
|
||||
marginBottom: "12px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{syncConflicts.slice(0, 30).map((item) => (
|
||||
<div key={`${item.table}-${item.key}`} style={{ marginBottom: "8px" }}>
|
||||
<div>
|
||||
<b>{item.table}</b> / <b>{item.key}</b>
|
||||
</div>
|
||||
<div>本地: {item.local_summary}</div>
|
||||
<div>远程: {item.remote_summary}</div>
|
||||
</div>
|
||||
))}
|
||||
{syncConflicts.length > 30 && <div>仅显示前 30 条冲突...</div>}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
|
||||
<button
|
||||
style={{ padding: "6px 10px", cursor: syncApplyLoading ? "not-allowed" : "pointer" }}
|
||||
disabled={syncApplyLoading}
|
||||
onClick={() => applySyncStrategy("keep_remote")}
|
||||
>
|
||||
保留远程
|
||||
</button>
|
||||
<button
|
||||
style={{ padding: "6px 10px", cursor: syncApplyLoading ? "not-allowed" : "pointer" }}
|
||||
disabled={syncApplyLoading}
|
||||
onClick={() => applySyncStrategy("keep_local")}
|
||||
>
|
||||
保留本地
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -228,6 +228,26 @@ const api = {
|
||||
}> => invoke("db_get_status"),
|
||||
dbSync: (): Promise<{ success: boolean; data: { success: boolean; message?: string } }> =>
|
||||
invoke("db_sync"),
|
||||
dbSyncPreview: (): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
can_sync: boolean
|
||||
need_sync: boolean
|
||||
local_only: number
|
||||
remote_only: number
|
||||
conflicts: Array<{
|
||||
table: string
|
||||
key: string
|
||||
local_summary: string
|
||||
remote_summary: string
|
||||
}>
|
||||
message?: string
|
||||
}
|
||||
}> => invoke("db_sync_preview"),
|
||||
dbSyncApply: (strategy: "keep_local" | "keep_remote"): Promise<{
|
||||
success: boolean
|
||||
data: { success: boolean; synced_records: number; resolved_conflicts: number; message?: string }
|
||||
}> => invoke("db_sync_apply", { strategy }),
|
||||
|
||||
// HTTP Server
|
||||
httpServerStart: (config?: {
|
||||
|
||||
Reference in New Issue
Block a user