feat(yedek): JSON tam hane yedeği backend (önizleme, geri yükleme, geri dönüş noktaları)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,997 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod account;
|
||||
pub mod asset;
|
||||
pub mod backup;
|
||||
pub mod bank_import;
|
||||
pub mod budget;
|
||||
pub mod calendar;
|
||||
|
||||
Reference in New Issue
Block a user