0d70576df7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
998 lines
34 KiB
Rust
998 lines
34 KiB
Rust
//! JSON tam hane yedeği (Faz 9 Adım 5; tasarım docs/DATA_MODEL.md "JSON yedeği").
|
||
//!
|
||
//! - **Dışa aktarma:** hanenin tüm kayıtları (silinmiş işaretliler dahil → iç bağlantılar
|
||
//! kopmaz, "geri al" birebir döner). Kullanıcılar, şifreler, davetler, otomatik kurlar yok.
|
||
//! - **Biçim:** okunabilir JSON. UUID `{"$uuid": "…"}`, zaman `{"$date": "…"}` (MongoDB
|
||
//! Extended JSON'daki adlarla), tutarlar tam sayı (kuruş / `_e6`). `checksum` = SHA-256
|
||
//! (`household` + `data`, anahtarlar sıralı) → yarım inmiş ya da değiştirilmiş dosya reddedilir.
|
||
//! - **Geri yükleme (yalnız sahip):** mevcut veri yedektekiyle **tamamen değiştirilir** (tek
|
||
//! transaction). Öncesinde mevcut durum sıkıştırılıp `restore_points`'e yazılır (son 3, 30 gün).
|
||
//! - **Kimlikler:** aynı haneye → korunur; başka haneye → hepsi yeniden üretilir ve tüm
|
||
//! referanslar (`*_id`) çevrilir; `*_by` (kişi) bu hanede yoksa geri yükleyen kişi olur.
|
||
|
||
use std::{
|
||
collections::{HashMap, HashSet},
|
||
io::{Read, Write},
|
||
};
|
||
|
||
use flate2::{Compression, read::GzDecoder, write::GzEncoder};
|
||
use mongodb::{
|
||
Database,
|
||
bson::{Binary, Bson, DateTime, Document, Uuid, doc, spec::BinarySubtype},
|
||
};
|
||
use serde_json::{Map, Number, Value};
|
||
use sha2::{Digest, Sha256};
|
||
|
||
use crate::{
|
||
db::{col, migrations},
|
||
error::{AppError, AppResult},
|
||
models::common::{new_id, now},
|
||
services::{
|
||
calendar::{format_date, parse_date},
|
||
seed,
|
||
},
|
||
};
|
||
|
||
pub const FORMAT: &str = "kakeibo-backup";
|
||
pub const FORMAT_VERSION: i64 = 1;
|
||
/// Yüklenebilecek en büyük yedek dosyası
|
||
pub const MAX_BYTES: usize = 20 * 1024 * 1024;
|
||
/// Saklanan geri dönüş noktası sayısı ve süresi
|
||
pub const KEEP_POINTS: usize = 3;
|
||
pub const KEEP_DAYS: i64 = 30;
|
||
/// Sıkıştırılmış geri dönüş noktası üst sınırı (MongoDB belge sınırı 16 MB)
|
||
const MAX_POINT_BYTES: usize = 15 * 1024 * 1024;
|
||
|
||
/// Yedekteki koleksiyonlar ve Türkçe adları (sıra dosyadaki sıradır).
|
||
pub const KINDS: &[(&str, &str)] = &[
|
||
(col::PERIODS, "Dönem"),
|
||
(col::PLAN_ITEMS, "Plan kalemi"),
|
||
(col::BUDGETS, "Bütçe"),
|
||
(col::ENTRIES, "Harcama"),
|
||
(col::CARRYOVERS, "Devir"),
|
||
(col::NOTES, "Not"),
|
||
(col::CATEGORIES, "Kategori"),
|
||
(col::PAYMENT_METHODS, "Ödeme türü"),
|
||
(col::ASSETS, "Varlık"),
|
||
(col::ASSET_TRANSACTIONS, "Varlık işlemi"),
|
||
(col::EXCHANGE_RATES, "Elle girilen kur"),
|
||
(col::IMPORT_RULES, "Ekstre kuralı"),
|
||
];
|
||
|
||
/// Uygulamanın `i32` yazdığı alanlar (geri kalan tam sayılar `i64`: tutarlar, `_e6`).
|
||
const INT32_KEYS: &[&str] = &["version", "sort_order"];
|
||
|
||
/// Bir hanenin bellekteki tam kopyası.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Snapshot {
|
||
pub household_id: Uuid,
|
||
pub household_name: String,
|
||
pub settings: Document,
|
||
pub exported_at: DateTime,
|
||
pub schema_version: i64,
|
||
pub app_version: String,
|
||
/// `KINDS` sırasıyla
|
||
pub data: Vec<(&'static str, Vec<Document>)>,
|
||
}
|
||
|
||
impl Snapshot {
|
||
/// Silinmemiş kayıt sayısı (kullanıcıya gösterilen)
|
||
pub fn active(&self, kind: &str) -> i64 {
|
||
self.data
|
||
.iter()
|
||
.find(|(k, _)| *k == kind)
|
||
.map_or(0, |(_, docs)| {
|
||
docs.iter()
|
||
.filter(|d| matches!(d.get("deleted_at"), None | Some(Bson::Null)))
|
||
.count() as i64
|
||
})
|
||
}
|
||
}
|
||
|
||
fn corrupt(detail: &str) -> AppError {
|
||
tracing::warn!("Bozuk yedek: {detail}");
|
||
AppError::BadRequest(
|
||
"Yedek dosyası bozuk ya da değiştirilmiş; doğrulama tutmadı. Başka bir yedek deneyin."
|
||
.into(),
|
||
)
|
||
}
|
||
|
||
fn not_backup() -> AppError {
|
||
AppError::BadRequest(
|
||
"Bu dosya bir Kakeibo yedeği değil. Ayarlar → Veri'den alınan .json yedek dosyasını seçin."
|
||
.into(),
|
||
)
|
||
}
|
||
|
||
// ---------- Toplama ----------
|
||
|
||
async fn drain(mut cursor: mongodb::Cursor<Document>) -> AppResult<Vec<Document>> {
|
||
let mut out = Vec::new();
|
||
while cursor.advance().await? {
|
||
out.push(cursor.deserialize_current()?);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Hanenin tüm kayıtlarını okur (silinmiş işaretliler dahil).
|
||
pub async fn collect(db: &Database, household_id: Uuid) -> AppResult<Snapshot> {
|
||
let h = db
|
||
.collection::<Document>(col::HOUSEHOLDS)
|
||
.find_one(doc! { "_id": household_id })
|
||
.await?
|
||
.ok_or(AppError::NotFound)?;
|
||
let mut data = Vec::with_capacity(KINDS.len());
|
||
for &(kind, _) in KINDS {
|
||
let cursor = db
|
||
.collection::<Document>(kind)
|
||
.find(doc! { "household_id": household_id })
|
||
.sort(doc! { "_id": 1 })
|
||
.await?;
|
||
data.push((kind, drain(cursor).await?));
|
||
}
|
||
Ok(Snapshot {
|
||
household_id,
|
||
household_name: h.get_str("name").unwrap_or_default().to_string(),
|
||
settings: h.get_document("settings").cloned().unwrap_or_default(),
|
||
exported_at: now(),
|
||
schema_version: i64::from(migrations::latest_version()),
|
||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||
data,
|
||
})
|
||
}
|
||
|
||
// ---------- BSON ↔ JSON ----------
|
||
|
||
fn bson_to_json(b: &Bson) -> Result<Value, String> {
|
||
Ok(match b {
|
||
Bson::Null => Value::Null,
|
||
Bson::Boolean(v) => Value::Bool(*v),
|
||
Bson::Int32(v) => Value::from(*v),
|
||
Bson::Int64(v) => Value::from(*v),
|
||
Bson::Double(v) => Value::Number(Number::from_f64(*v).ok_or("NaN")?),
|
||
Bson::String(s) => Value::String(s.clone()),
|
||
Bson::Array(a) => Value::Array(a.iter().map(bson_to_json).collect::<Result<_, _>>()?),
|
||
Bson::Document(d) => doc_to_json(d)?,
|
||
Bson::DateTime(dt) => {
|
||
let s = dt.try_to_rfc3339_string().map_err(|e| e.to_string())?;
|
||
Value::Object(Map::from_iter([("$date".into(), Value::String(s))]))
|
||
}
|
||
Bson::Binary(Binary {
|
||
subtype: BinarySubtype::Uuid,
|
||
bytes,
|
||
}) => {
|
||
let arr: [u8; 16] = bytes.as_slice().try_into().map_err(|_| "uuid")?;
|
||
Value::Object(Map::from_iter([(
|
||
"$uuid".into(),
|
||
Value::String(Uuid::from_bytes(arr).to_string()),
|
||
)]))
|
||
}
|
||
other => return Err(format!("desteklenmeyen tür: {:?}", other.element_type())),
|
||
})
|
||
}
|
||
|
||
fn doc_to_json(d: &Document) -> Result<Value, String> {
|
||
let mut m = Map::new();
|
||
for (k, v) in d {
|
||
m.insert(k.clone(), bson_to_json(v)?);
|
||
}
|
||
Ok(Value::Object(m))
|
||
}
|
||
|
||
fn json_to_bson(v: &Value, key: &str) -> Option<Bson> {
|
||
Some(match v {
|
||
Value::Null => Bson::Null,
|
||
Value::Bool(b) => Bson::Boolean(*b),
|
||
Value::Number(n) => {
|
||
if let Some(i) = n.as_i64() {
|
||
if INT32_KEYS.contains(&key) {
|
||
Bson::Int32(i32::try_from(i).ok()?)
|
||
} else {
|
||
Bson::Int64(i)
|
||
}
|
||
} else if n.is_u64() {
|
||
return None;
|
||
} else {
|
||
Bson::Double(n.as_f64()?)
|
||
}
|
||
}
|
||
Value::String(s) => Bson::String(s.clone()),
|
||
Value::Array(a) => Bson::Array(
|
||
a.iter()
|
||
.map(|x| json_to_bson(x, key))
|
||
.collect::<Option<_>>()?,
|
||
),
|
||
Value::Object(m) => {
|
||
if m.len() == 1 {
|
||
if let Some(Value::String(s)) = m.get("$uuid") {
|
||
return Uuid::parse_str(s).ok().map(Bson::from);
|
||
}
|
||
if let Some(Value::String(s)) = m.get("$date") {
|
||
return DateTime::parse_rfc3339_str(s).ok().map(Bson::DateTime);
|
||
}
|
||
}
|
||
Bson::Document(json_to_doc(m)?)
|
||
}
|
||
})
|
||
}
|
||
|
||
fn json_to_doc(m: &Map<String, Value>) -> Option<Document> {
|
||
let mut d = Document::new();
|
||
for (k, v) in m {
|
||
// Operatör ya da yol gibi görünen anahtarlar asla veritabanına gitmez
|
||
if k.starts_with('$') || k.contains('.') || k.contains('\0') {
|
||
return None;
|
||
}
|
||
d.insert(k.clone(), json_to_bson(v, k)?);
|
||
}
|
||
Some(d)
|
||
}
|
||
|
||
/// `i32`'ye sığan tam sayıları `Int32` yapar (iç içe belgeler dahil)
|
||
fn small_ints(d: Document) -> Document {
|
||
fn conv(b: Bson) -> Bson {
|
||
match b {
|
||
Bson::Int64(v) => i32::try_from(v).map_or(Bson::Int64(v), Bson::Int32),
|
||
Bson::Document(d) => Bson::Document(small_ints(d)),
|
||
Bson::Array(a) => Bson::Array(a.into_iter().map(conv).collect()),
|
||
other => other,
|
||
}
|
||
}
|
||
d.into_iter().map(|(k, v)| (k, conv(v))).collect()
|
||
}
|
||
|
||
/// Anahtarları her düzeyde sıralı kopya (dosya biçimlendirmesinden bağımsız özet için)
|
||
fn canonical(v: &Value) -> Value {
|
||
match v {
|
||
Value::Object(m) => {
|
||
let mut keys: Vec<&String> = m.keys().collect();
|
||
keys.sort();
|
||
Value::Object(
|
||
keys.into_iter()
|
||
.map(|k| (k.clone(), canonical(&m[k])))
|
||
.collect(),
|
||
)
|
||
}
|
||
Value::Array(a) => Value::Array(a.iter().map(canonical).collect()),
|
||
other => other.clone(),
|
||
}
|
||
}
|
||
|
||
fn checksum(household: &Value, data: &Value) -> String {
|
||
let body = canonical(&Value::Object(Map::from_iter([
|
||
("household".to_string(), household.clone()),
|
||
("data".to_string(), data.clone()),
|
||
])));
|
||
let hash = Sha256::digest(serde_json::to_string(&body).unwrap_or_default().as_bytes());
|
||
format!("sha256:{hash:x}")
|
||
}
|
||
|
||
// ---------- Yazma (dosya) ----------
|
||
|
||
/// Yedek dosyasının metni. Başlık önce, sonra `data` (her kayıt bir satır).
|
||
pub fn to_json(s: &Snapshot) -> AppResult<String> {
|
||
let internal = |e: String| AppError::Internal(format!("yedek dönüştürme: {e}"));
|
||
let household = serde_json::json!({
|
||
"id": s.household_id.to_string(),
|
||
"name": s.household_name,
|
||
"settings": doc_to_json(&s.settings).map_err(internal)?,
|
||
});
|
||
let mut data = Map::new();
|
||
let mut counts = Map::new();
|
||
for (kind, docs) in &s.data {
|
||
let arr = docs
|
||
.iter()
|
||
.map(doc_to_json)
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(internal)?;
|
||
data.insert(kind.to_string(), Value::Array(arr));
|
||
counts.insert(kind.to_string(), Value::from(s.active(kind)));
|
||
}
|
||
let data = Value::Object(data);
|
||
let header: [(&str, Value); 8] = [
|
||
("format", FORMAT.into()),
|
||
("format_version", FORMAT_VERSION.into()),
|
||
("schema_version", s.schema_version.into()),
|
||
("app_version", s.app_version.clone().into()),
|
||
(
|
||
"exported_at",
|
||
s.exported_at
|
||
.try_to_rfc3339_string()
|
||
.map_err(|e| internal(e.to_string()))?
|
||
.into(),
|
||
),
|
||
("household", household.clone()),
|
||
("counts", Value::Object(counts)),
|
||
("checksum", checksum(&household, &data).into()),
|
||
];
|
||
|
||
let j = |v: &Value| serde_json::to_string(v).unwrap_or_default();
|
||
let mut out = String::from("{\n");
|
||
for (k, v) in &header {
|
||
out.push_str(&format!(" {}: {},\n", j(&(*k).into()), j(v)));
|
||
}
|
||
out.push_str(" \"data\": {");
|
||
let Value::Object(map) = &data else {
|
||
unreachable!()
|
||
};
|
||
for (i, (kind, arr)) in map.iter().enumerate() {
|
||
out.push_str(if i == 0 { "\n" } else { ",\n" });
|
||
out.push_str(&format!(" {}: [", j(&kind.as_str().into())));
|
||
let items = arr.as_array().map(Vec::as_slice).unwrap_or_default();
|
||
for (n, item) in items.iter().enumerate() {
|
||
out.push_str(if n == 0 { "\n " } else { ",\n " });
|
||
out.push_str(&j(item));
|
||
}
|
||
out.push_str(if items.is_empty() { "]" } else { "\n ]" });
|
||
}
|
||
out.push_str("\n }\n}\n");
|
||
Ok(out)
|
||
}
|
||
|
||
// ---------- Okuma (dosya) ----------
|
||
|
||
/// Dosyayı okur ve doğrular: biçim, sürüm, özet, kayıt yapısı.
|
||
pub fn parse(bytes: &[u8]) -> AppResult<Snapshot> {
|
||
let v: Value = serde_json::from_slice(bytes).map_err(|_| not_backup())?;
|
||
let o = v.as_object().ok_or_else(not_backup)?;
|
||
if o.get("format").and_then(Value::as_str) != Some(FORMAT) {
|
||
return Err(not_backup());
|
||
}
|
||
let newer = || {
|
||
AppError::BadRequest(
|
||
"Bu yedek uygulamanın daha yeni bir sürümüyle alınmış. Önce uygulamayı güncelleyin."
|
||
.into(),
|
||
)
|
||
};
|
||
let format_version = o.get("format_version").and_then(Value::as_i64).unwrap_or(0);
|
||
let schema_version = o.get("schema_version").and_then(Value::as_i64).unwrap_or(0);
|
||
if format_version < 1 || schema_version < 1 {
|
||
return Err(corrupt("sürüm alanı yok"));
|
||
}
|
||
if format_version > FORMAT_VERSION || schema_version > i64::from(migrations::latest_version()) {
|
||
return Err(newer());
|
||
}
|
||
|
||
let household = o.get("household").ok_or_else(|| corrupt("household yok"))?;
|
||
let data = o.get("data").ok_or_else(|| corrupt("data yok"))?;
|
||
if o.get("checksum").and_then(Value::as_str) != Some(checksum(household, data).as_str()) {
|
||
return Err(corrupt("checksum"));
|
||
}
|
||
|
||
let household_id = household
|
||
.get("id")
|
||
.and_then(Value::as_str)
|
||
.and_then(|s| Uuid::parse_str(s).ok())
|
||
.ok_or_else(|| corrupt("household.id"))?;
|
||
let settings = match household.get("settings") {
|
||
// Ayarlar uygulamada i32 yazılır (ör. yüzdeler)
|
||
Some(Value::Object(m)) => small_ints(json_to_doc(m).ok_or_else(|| corrupt("settings"))?),
|
||
_ => Document::new(),
|
||
};
|
||
let data = data
|
||
.as_object()
|
||
.ok_or_else(|| corrupt("data nesne değil"))?;
|
||
if let Some(k) = data.keys().find(|k| !KINDS.iter().any(|(c, _)| c == k)) {
|
||
return Err(corrupt(&format!("bilinmeyen koleksiyon {k}")));
|
||
}
|
||
|
||
let mut seen = HashSet::new();
|
||
let mut out = Vec::with_capacity(KINDS.len());
|
||
for &(kind, _) in KINDS {
|
||
let items = match data.get(kind) {
|
||
None => &Vec::new(),
|
||
Some(Value::Array(a)) => a,
|
||
Some(_) => return Err(corrupt(kind)),
|
||
};
|
||
let mut docs = Vec::with_capacity(items.len());
|
||
for item in items {
|
||
let d = item
|
||
.as_object()
|
||
.and_then(json_to_doc)
|
||
.ok_or_else(|| corrupt(kind))?;
|
||
let id = match d.get("_id") {
|
||
Some(Bson::Binary(b)) if b.subtype == BinarySubtype::Uuid => b.bytes.clone(),
|
||
_ => return Err(corrupt(&format!("{kind}: _id"))),
|
||
};
|
||
if d.get("household_id") != Some(&Bson::from(household_id)) || !seen.insert(id) {
|
||
return Err(corrupt(&format!("{kind}: household_id / tekrar eden _id")));
|
||
}
|
||
docs.push(d);
|
||
}
|
||
out.push((kind, docs));
|
||
}
|
||
|
||
Ok(Snapshot {
|
||
household_id,
|
||
household_name: household
|
||
.get("name")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default()
|
||
.to_string(),
|
||
settings,
|
||
exported_at: o
|
||
.get("exported_at")
|
||
.and_then(Value::as_str)
|
||
.and_then(|s| DateTime::parse_rfc3339_str(s).ok())
|
||
.ok_or_else(|| corrupt("exported_at"))?,
|
||
schema_version,
|
||
app_version: o
|
||
.get("app_version")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default()
|
||
.to_string(),
|
||
data: out,
|
||
})
|
||
}
|
||
|
||
// ---------- Önizleme ----------
|
||
|
||
#[derive(Debug)]
|
||
pub struct KindCount {
|
||
pub kind: &'static str,
|
||
pub label: &'static str,
|
||
pub backup: i64,
|
||
pub current: i64,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub struct Preview {
|
||
pub source_name: String,
|
||
pub same_household: bool,
|
||
pub exported_at: DateTime,
|
||
pub app_version: String,
|
||
pub counts: Vec<KindCount>,
|
||
/// Yedekteki dönemlerin ilk ve son günü (`"YYYY-MM-DD"`)
|
||
pub first_date: Option<String>,
|
||
pub last_date: Option<String>,
|
||
/// Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt sayısı
|
||
pub changed_since: i64,
|
||
pub warnings: Vec<String>,
|
||
}
|
||
|
||
pub async fn preview(db: &Database, household_id: Uuid, snap: &Snapshot) -> AppResult<Preview> {
|
||
let same = snap.household_id == household_id;
|
||
let mut counts = Vec::with_capacity(KINDS.len());
|
||
let mut changed_since = 0;
|
||
for &(kind, label) in KINDS {
|
||
let current = db
|
||
.collection::<Document>(kind)
|
||
.count_documents(doc! { "household_id": household_id, "deleted_at": null })
|
||
.await? as i64;
|
||
if same {
|
||
changed_since += db
|
||
.collection::<Document>(kind)
|
||
.count_documents(
|
||
doc! { "household_id": household_id, "updated_at": { "$gt": snap.exported_at } },
|
||
)
|
||
.await? as i64;
|
||
}
|
||
counts.push(KindCount {
|
||
kind,
|
||
label,
|
||
backup: snap.active(kind),
|
||
current,
|
||
});
|
||
}
|
||
|
||
let periods = snap
|
||
.data
|
||
.iter()
|
||
.find(|(k, _)| *k == col::PERIODS)
|
||
.map(|(_, d)| d.as_slice())
|
||
.unwrap_or_default();
|
||
let live = periods
|
||
.iter()
|
||
.filter(|d| matches!(d.get("deleted_at"), None | Some(Bson::Null)));
|
||
let first_date = live
|
||
.clone()
|
||
.filter_map(|d| d.get_str("start_date").ok())
|
||
.min()
|
||
.map(str::to_string);
|
||
let last_date = live
|
||
.filter_map(|d| d.get_str("end_date").ok())
|
||
.max()
|
||
.and_then(parse_date)
|
||
.map(|d| format_date(d - time::Duration::days(1)));
|
||
|
||
let mut warnings = Vec::new();
|
||
if !same {
|
||
warnings.push(format!(
|
||
"Bu yedek başka bir haneden alınmış (“{}”). Kayıtlar bu haneye kopyalanır; kaydı giren kişi bu hanede yoksa kayıt sizin adınıza görünür.",
|
||
snap.household_name
|
||
));
|
||
}
|
||
if changed_since > 0 {
|
||
warnings.push(format!(
|
||
"Yedek alındıktan sonra eklediğiniz ya da değiştirdiğiniz {changed_since} kayıt bu işlemle kaybolur. Gerekirse sonradan “Geri al” ile dönebilirsiniz."
|
||
));
|
||
}
|
||
if snap.schema_version < i64::from(migrations::latest_version()) {
|
||
warnings.push(
|
||
"Bu yedek uygulamanın eski bir sürümüyle alınmış; geri yüklenirken güncellenir.".into(),
|
||
);
|
||
}
|
||
if snap.active(col::PERIODS) == 0 {
|
||
warnings.push("Yedekte hiç dönem yok; geri yüklenirse hane boş kalır.".into());
|
||
}
|
||
|
||
Ok(Preview {
|
||
source_name: snap.household_name.clone(),
|
||
same_household: same,
|
||
exported_at: snap.exported_at,
|
||
app_version: snap.app_version.clone(),
|
||
counts,
|
||
first_date,
|
||
last_date,
|
||
changed_since,
|
||
warnings,
|
||
})
|
||
}
|
||
|
||
// ---------- Kimlik çevirme ----------
|
||
|
||
fn uuid_of(b: Option<&Bson>) -> Option<Uuid> {
|
||
match b {
|
||
Some(Bson::Binary(bin)) if bin.subtype == BinarySubtype::Uuid => {
|
||
<[u8; 16]>::try_from(bin.bytes.as_slice())
|
||
.ok()
|
||
.map(Uuid::from_bytes)
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Yedeği hedef haneye uyarlar. Aynı hane → kimlikler aynı; başka hane → yeni kimlikler,
|
||
/// tüm referanslar çevrilir. `*_by` alanı hanede olmayan biriyse geri yükleyen kişi olur.
|
||
fn retarget(
|
||
snap: &Snapshot,
|
||
target: Uuid,
|
||
restorer: Uuid,
|
||
members: &HashSet<Uuid>,
|
||
) -> Vec<(&'static str, Vec<Document>)> {
|
||
let same = snap.household_id == target;
|
||
let mut map: HashMap<Uuid, Uuid> = HashMap::new();
|
||
for (_, docs) in &snap.data {
|
||
for d in docs {
|
||
if let Some(old) = uuid_of(d.get("_id")) {
|
||
map.insert(old, if same { old } else { new_id() });
|
||
}
|
||
}
|
||
}
|
||
|
||
fn fix(b: &mut Bson, key: &str, ctx: &Ctx) {
|
||
match b {
|
||
Bson::Binary(bin) if bin.subtype == BinarySubtype::Uuid => {
|
||
let Ok(arr) = <[u8; 16]>::try_from(bin.bytes.as_slice()) else {
|
||
return;
|
||
};
|
||
let old = Uuid::from_bytes(arr);
|
||
let new = if key == "household_id" {
|
||
ctx.target
|
||
} else if key.ends_with("_by") {
|
||
if ctx.members.contains(&old) {
|
||
old
|
||
} else {
|
||
ctx.restorer
|
||
}
|
||
} else {
|
||
// Yedekte olmayan (zaten kopuk) referans olduğu gibi kalır
|
||
*ctx.map.get(&old).unwrap_or(&old)
|
||
};
|
||
*b = Bson::from(new);
|
||
}
|
||
Bson::Document(d) => {
|
||
for (k, v) in d.iter_mut() {
|
||
fix(v, k, ctx);
|
||
}
|
||
}
|
||
Bson::Array(a) => {
|
||
for v in a.iter_mut() {
|
||
fix(v, key, ctx);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
struct Ctx<'a> {
|
||
target: Uuid,
|
||
restorer: Uuid,
|
||
members: &'a HashSet<Uuid>,
|
||
map: &'a HashMap<Uuid, Uuid>,
|
||
}
|
||
let ctx = Ctx {
|
||
target,
|
||
restorer,
|
||
members,
|
||
map: &map,
|
||
};
|
||
|
||
snap.data
|
||
.iter()
|
||
.map(|(kind, docs)| {
|
||
let docs = docs
|
||
.iter()
|
||
.map(|d| {
|
||
let mut b = Bson::Document(d.clone());
|
||
fix(&mut b, "", &ctx);
|
||
match b {
|
||
Bson::Document(d) => d,
|
||
_ => unreachable!(),
|
||
}
|
||
})
|
||
.collect();
|
||
(*kind, docs)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
// ---------- Geri yükleme ----------
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Reason {
|
||
/// JSON dosyasından geri yüklemeden önce
|
||
BeforeRestore,
|
||
/// Geri dönüş noktasına dönmeden önce
|
||
BeforeUndo,
|
||
}
|
||
|
||
impl Reason {
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
Reason::BeforeRestore => "before_restore",
|
||
Reason::BeforeUndo => "before_undo",
|
||
}
|
||
}
|
||
}
|
||
|
||
fn gzip(text: &str) -> AppResult<Vec<u8>> {
|
||
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
|
||
enc.write_all(text.as_bytes())
|
||
.and_then(|_| enc.finish())
|
||
.map_err(|e| AppError::Internal(format!("sıkıştırma: {e}")))
|
||
}
|
||
|
||
fn gunzip(bytes: &[u8]) -> AppResult<Vec<u8>> {
|
||
let mut out = Vec::new();
|
||
GzDecoder::new(bytes)
|
||
.take(MAX_BYTES as u64 * 4)
|
||
.read_to_end(&mut out)
|
||
.map_err(|e| AppError::Internal(format!("geri dönüş noktası açılamadı: {e}")))?;
|
||
Ok(out)
|
||
}
|
||
|
||
/// Mevcut veriyi geri dönüş noktasına alır ve hanenin verisini `snap` ile değiştirir.
|
||
/// Hepsi tek transaction: yarıda kalırsa hiçbir şey değişmez. Dönen: geri dönüş noktası kimliği.
|
||
pub async fn restore(
|
||
db: &Database,
|
||
household_id: Uuid,
|
||
restorer: Uuid,
|
||
snap: &Snapshot,
|
||
reason: Reason,
|
||
) -> AppResult<Uuid> {
|
||
// 1) Şu anki durumun kopyası (sıkıştırılmış)
|
||
let current = collect(db, household_id).await?;
|
||
let packed = gzip(&to_json(¤t)?)?;
|
||
if packed.len() > MAX_POINT_BYTES {
|
||
return Err(AppError::BadRequest(
|
||
"Mevcut veriniz geri dönüş noktası için çok büyük; güvenlik için geri yükleme yapılmadı."
|
||
.into(),
|
||
));
|
||
}
|
||
|
||
// 2) Yedeği bu haneye uyarla
|
||
let members: HashSet<Uuid> = {
|
||
let cursor = db
|
||
.collection::<Document>(col::USERS)
|
||
.find(doc! { "household_id": household_id })
|
||
.projection(doc! { "_id": 1 })
|
||
.await?;
|
||
drain(cursor)
|
||
.await?
|
||
.iter()
|
||
.filter_map(|d| uuid_of(d.get("_id")))
|
||
.collect()
|
||
};
|
||
let docs = retarget(snap, household_id, restorer, &members);
|
||
|
||
// 3) Tek transaction: nokta yaz → sil → ekle → ayarlar
|
||
let point_id = new_id();
|
||
let ts = now();
|
||
let mut counts = Document::new();
|
||
for &(kind, _) in KINDS {
|
||
counts.insert(kind, current.active(kind));
|
||
}
|
||
let mut session = db.client().start_session().await?;
|
||
session.start_transaction().await?;
|
||
let result = async {
|
||
db.collection::<Document>(col::RESTORE_POINTS)
|
||
.insert_one(doc! {
|
||
"_id": point_id,
|
||
"household_id": household_id,
|
||
"created_at": ts,
|
||
"created_by": restorer,
|
||
"reason": reason.as_str(),
|
||
"counts": counts,
|
||
"size": packed.len() as i64,
|
||
"data": Binary { subtype: BinarySubtype::Generic, bytes: packed },
|
||
})
|
||
.session(&mut session)
|
||
.await?;
|
||
for (kind, list) in &docs {
|
||
db.collection::<Document>(kind)
|
||
.delete_many(doc! { "household_id": household_id })
|
||
.session(&mut session)
|
||
.await?;
|
||
if !list.is_empty() {
|
||
db.collection::<Document>(kind)
|
||
.insert_many(list)
|
||
.session(&mut session)
|
||
.await?;
|
||
}
|
||
}
|
||
let mut set = doc! { "updated_at": ts };
|
||
if !snap.settings.is_empty() {
|
||
set.insert("settings", snap.settings.clone());
|
||
}
|
||
if snap.household_id == household_id && !snap.household_name.is_empty() {
|
||
set.insert("name", snap.household_name.clone());
|
||
}
|
||
db.collection::<Document>(col::HOUSEHOLDS)
|
||
.update_one(
|
||
doc! { "_id": household_id },
|
||
doc! { "$set": set, "$inc": { "version": 1 } },
|
||
)
|
||
.session(&mut session)
|
||
.await?;
|
||
Ok::<(), AppError>(())
|
||
}
|
||
.await;
|
||
match result {
|
||
Ok(()) => session.commit_transaction().await?,
|
||
Err(e) => {
|
||
let _ = session.abort_transaction().await;
|
||
return Err(e);
|
||
}
|
||
}
|
||
|
||
// 4) Eski sürüm yedeği → o sürümden sonra gelen hane düzeyi migration'lar
|
||
if snap.schema_version < 2
|
||
&& db
|
||
.collection::<Document>(col::ASSETS)
|
||
.find_one(doc! { "household_id": household_id })
|
||
.await?
|
||
.is_none()
|
||
{
|
||
db.collection::<Document>(col::ASSETS)
|
||
.insert_many(seed::assets(household_id))
|
||
.await?;
|
||
}
|
||
|
||
prune(db, household_id).await?;
|
||
tracing::info!(
|
||
"Hane {household_id} geri yüklendi ({}); geri dönüş noktası {point_id}",
|
||
reason.as_str()
|
||
);
|
||
Ok(point_id)
|
||
}
|
||
|
||
// ---------- Geri dönüş noktaları ----------
|
||
|
||
#[derive(Debug)]
|
||
pub struct PointInfo {
|
||
pub id: Uuid,
|
||
pub created_at: DateTime,
|
||
pub reason: String,
|
||
pub periods: i64,
|
||
pub entries: i64,
|
||
pub size: i64,
|
||
}
|
||
|
||
/// En yeniden eskiye (veri olmadan)
|
||
pub async fn points(db: &Database, household_id: Uuid) -> AppResult<Vec<PointInfo>> {
|
||
let cursor = db
|
||
.collection::<Document>(col::RESTORE_POINTS)
|
||
.find(doc! { "household_id": household_id })
|
||
.projection(doc! { "data": 0 })
|
||
.sort(doc! { "created_at": -1 })
|
||
.await?;
|
||
let docs = drain(cursor).await?;
|
||
let int = |d: &Document, k: &str| match d.get(k) {
|
||
Some(Bson::Int64(v)) => *v,
|
||
Some(Bson::Int32(v)) => i64::from(*v),
|
||
_ => 0,
|
||
};
|
||
Ok(docs
|
||
.iter()
|
||
.filter_map(|d| {
|
||
let id = uuid_of(d.get("_id"))?;
|
||
let counts = d.get_document("counts").cloned().unwrap_or_default();
|
||
Some(PointInfo {
|
||
id,
|
||
created_at: d.get_datetime("created_at").ok().copied()?,
|
||
reason: d.get_str("reason").unwrap_or_default().to_string(),
|
||
periods: int(&counts, col::PERIODS),
|
||
entries: int(&counts, col::ENTRIES),
|
||
size: int(d, "size"),
|
||
})
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// Geri dönüş noktasına döner (öncesinde şu anki durum da nokta olur → bu da geri alınabilir).
|
||
pub async fn undo(
|
||
db: &Database,
|
||
household_id: Uuid,
|
||
restorer: Uuid,
|
||
point_id: Uuid,
|
||
) -> AppResult<Uuid> {
|
||
let d = db
|
||
.collection::<Document>(col::RESTORE_POINTS)
|
||
.find_one(doc! { "_id": point_id, "household_id": household_id })
|
||
.await?
|
||
.ok_or(AppError::NotFound)?;
|
||
let bytes = match d.get("data") {
|
||
Some(Bson::Binary(b)) => gunzip(&b.bytes)?,
|
||
_ => return Err(AppError::Internal("geri dönüş noktasında veri yok".into())),
|
||
};
|
||
let snap = parse(&bytes)?;
|
||
restore(db, household_id, restorer, &snap, Reason::BeforeUndo).await
|
||
}
|
||
|
||
/// Son `KEEP_POINTS` nokta kalır; `KEEP_DAYS` günden eskiler silinir.
|
||
pub async fn prune(db: &Database, household_id: Uuid) -> AppResult<()> {
|
||
let list = points(db, household_id).await?;
|
||
let cutoff = now().timestamp_millis() - KEEP_DAYS * 86_400_000;
|
||
let old: Vec<Uuid> = list
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(i, p)| *i >= KEEP_POINTS || p.created_at.timestamp_millis() < cutoff)
|
||
.map(|(_, p)| p.id)
|
||
.collect();
|
||
if !old.is_empty() {
|
||
db.collection::<Document>(col::RESTORE_POINTS)
|
||
.delete_many(doc! { "household_id": household_id, "_id": { "$in": old } })
|
||
.await?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Dışa aktarma zamanı ("son yedek X gün önce" hatırlatması için)
|
||
pub async fn mark_exported(db: &Database, household_id: Uuid) -> AppResult<()> {
|
||
db.collection::<Document>(col::HOUSEHOLDS)
|
||
.update_one(
|
||
doc! { "_id": household_id },
|
||
doc! { "$set": { "last_backup_at": now() } },
|
||
)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn last_exported(db: &Database, household_id: Uuid) -> AppResult<Option<DateTime>> {
|
||
Ok(db
|
||
.collection::<Document>(col::HOUSEHOLDS)
|
||
.find_one(doc! { "_id": household_id })
|
||
.await?
|
||
.and_then(|h| h.get_datetime("last_backup_at").ok().copied()))
|
||
}
|
||
|
||
/// Dosya adı için hane adı: `Çakır Hanesi` → `cakir-hanesi`
|
||
pub fn slug(name: &str) -> String {
|
||
let folded = crate::services::bank_import::fold(name);
|
||
let s: String = folded
|
||
.chars()
|
||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||
.collect();
|
||
let s = s
|
||
.split('-')
|
||
.filter(|p| !p.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("-");
|
||
if s.is_empty() { "hane".into() } else { s }
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn sample() -> Snapshot {
|
||
let hid = new_id();
|
||
let pid = new_id();
|
||
let ts = DateTime::from_millis(1_763_000_000_123);
|
||
Snapshot {
|
||
household_id: hid,
|
||
household_name: "Çakır Hanesi".into(),
|
||
settings: doc! { "currency": "TRY", "budget_split_pct": { "needs": 50 } },
|
||
exported_at: ts,
|
||
schema_version: i64::from(migrations::latest_version()),
|
||
app_version: "0.1.0".into(),
|
||
data: KINDS
|
||
.iter()
|
||
.map(|&(k, _)| {
|
||
let docs = if k == col::PERIODS {
|
||
vec![doc! {
|
||
"_id": pid, "household_id": hid, "start_date": "2026-11-01",
|
||
"end_date": "2026-12-01", "created_at": ts, "deleted_at": null,
|
||
"version": 3_i32, "to_savings": 1_250_i64, "rate": 1.5_f64,
|
||
"tags": ["a", "b"], "review": { "note": "İyi", "reviewed_by": hid },
|
||
}]
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
(k, docs)
|
||
})
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn json_round_trip_keeps_types() {
|
||
let s = sample();
|
||
let text = to_json(&s).unwrap();
|
||
assert!(text.starts_with("{\n \"format\": \"kakeibo-backup\""));
|
||
let back = parse(text.as_bytes()).unwrap();
|
||
assert_eq!(back.household_id, s.household_id);
|
||
assert_eq!(back.household_name, "Çakır Hanesi");
|
||
assert_eq!(back.settings, s.settings.clone());
|
||
assert_eq!(back.exported_at, s.exported_at);
|
||
assert_eq!(
|
||
back.data[0].1, s.data[0].1,
|
||
"belge birebir aynı (türler dahil)"
|
||
);
|
||
// Yeniden biçimlendirilmiş (ör. düzenleyicide açılıp kaydedilmiş) dosya da geçerli
|
||
let pretty =
|
||
serde_json::to_string_pretty(&serde_json::from_str::<Value>(&text).unwrap()).unwrap();
|
||
assert!(parse(pretty.as_bytes()).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn tampered_or_foreign_files_are_rejected() {
|
||
let text = to_json(&sample()).unwrap();
|
||
let msg = |b: &[u8]| parse(b).unwrap_err().to_string();
|
||
assert!(msg(text.replace("1250", "9250").as_bytes()).contains("bozuk"));
|
||
assert!(msg(&text.as_bytes()[..text.len() / 2]).contains("Kakeibo yedeği değil"));
|
||
assert!(msg(b"{\"a\": 1}").contains("Kakeibo yedeği değil"));
|
||
let newer = text.replace("\"format_version\": 1", "\"format_version\": 2");
|
||
assert!(msg(newer.as_bytes()).contains("daha yeni"));
|
||
}
|
||
|
||
#[test]
|
||
fn retarget_maps_ids_and_people() {
|
||
let s = sample();
|
||
let pid = uuid_of(s.data[0].1[0].get("_id")).unwrap();
|
||
let target = new_id();
|
||
let me = new_id();
|
||
let mut plan = s.clone();
|
||
plan.data[1].1.push(doc! {
|
||
"_id": new_id(), "household_id": s.household_id, "period_id": pid,
|
||
"created_by": new_id(), "missing_id": new_id(),
|
||
});
|
||
|
||
// Başka hane: yeni kimlikler, referans çevrilir, kişi → geri yükleyen
|
||
let out = retarget(&plan, target, me, &HashSet::new());
|
||
let p = &out[0].1[0];
|
||
let item = &out[1].1[0];
|
||
assert_ne!(p.get("_id"), Some(&Bson::from(pid)));
|
||
assert_eq!(item.get("period_id"), p.get("_id"));
|
||
assert_eq!(item.get("household_id"), Some(&Bson::from(target)));
|
||
assert_eq!(item.get("created_by"), Some(&Bson::from(me)));
|
||
assert_eq!(
|
||
p.get_document("review").unwrap().get("reviewed_by"),
|
||
Some(&Bson::from(me)),
|
||
"iç içe *_by"
|
||
);
|
||
assert_eq!(item.get("missing_id"), plan.data[1].1[0].get("missing_id"));
|
||
|
||
// Aynı hane: kimlikler aynı
|
||
let out = retarget(&plan, s.household_id, me, &HashSet::new());
|
||
assert_eq!(out[0].1[0].get("_id"), Some(&Bson::from(pid)));
|
||
}
|
||
|
||
#[test]
|
||
fn file_name_slug() {
|
||
assert_eq!(slug("Çakır Hanesi"), "cakir-hanesi");
|
||
assert_eq!(slug(" İZMİR / Ev "), "izmir-ev");
|
||
assert_eq!(slug("***"), "hane");
|
||
}
|
||
}
|