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:
2026-11-13 20:49:13 +03:00
parent 6e1000afd2
commit 0d70576df7
20 changed files with 1768 additions and 2 deletions
+219
View File
@@ -0,0 +1,219 @@
//! JSON tam hane yedeği (`services::backup`).
//!
//! | Yöntem | Yol | Kim | İş |
//! |---|---|---|---|
//! | GET | `/api/backup/download` | üye | yedek dosyası (indirme) |
//! | GET | `/api/backup/status` | üye | son yedek zamanı + geri dönüş noktaları (yalnız sahip görür) |
//! | POST | `/api/backup/preview` | sahip | gövde: yedek dosyası → ne değişecek (hiçbir şey yazılmaz) |
//! | POST | `/api/backup/restore` | sahip | gövde: yedek dosyası → hane verisi değiştirilir |
//! | POST | `/api/backup/restore-points/{id}/restore` | sahip | geri dönüş noktasına dön ("geri al") |
use axum::{
Json,
body::Bytes,
extract::{Path, State},
http::header,
response::IntoResponse,
};
use mongodb::bson::DateTime;
use serde::Serialize;
use ts_rs::TS;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
models::user::Role,
services::{
backup::{self, Reason},
calendar::{format_date, today_tr},
},
state::AppState,
};
fn iso(d: DateTime) -> String {
d.try_to_rfc3339_string().unwrap_or_default()
}
fn parse_id(id: &str) -> AppResult<mongodb::bson::Uuid> {
mongodb::bson::Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
pub async fn download(State(s): State<AppState>, auth: AuthUser) -> AppResult<impl IntoResponse> {
let snap = backup::collect(&s.db, auth.household_id).await?;
let body = backup::to_json(&snap)?;
backup::mark_exported(&s.db, auth.household_id).await?;
let file = format!(
"kakeibo-yedek-{}-{}.json",
backup::slug(&snap.household_name),
format_date(today_tr())
);
Ok((
[
(
header::CONTENT_TYPE,
"application/json; charset=utf-8".to_string(),
),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{file}\""),
),
(header::CACHE_CONTROL, "no-store".to_string()),
],
body,
))
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct RestorePoint {
id: String,
/// RFC 3339
created_at: String,
/// `before_restore`: dosyadan geri yüklemeden önce · `before_undo`: geri almadan önce
#[ts(type = "\"before_restore\" | \"before_undo\"")]
reason: String,
#[ts(type = "number")]
periods: i64,
#[ts(type = "number")]
entries: i64,
/// Sıkıştırılmış boyut (bayt)
#[ts(type = "number")]
size: i64,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct BackupStatus {
/// Son dışa aktarma (RFC 3339); hiç alınmadıysa `null`
last_backup_at: Option<String>,
/// En yeniden eskiye; sahip olmayana boş
restore_points: Vec<RestorePoint>,
keep_points: u32,
keep_days: u32,
}
pub async fn status(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<BackupStatus>> {
let points = if auth.role == Role::Owner {
backup::points(&s.db, auth.household_id).await?
} else {
Vec::new()
};
Ok(Json(BackupStatus {
last_backup_at: backup::last_exported(&s.db, auth.household_id)
.await?
.map(iso),
restore_points: points
.into_iter()
.map(|p| RestorePoint {
id: p.id.to_string(),
created_at: iso(p.created_at),
reason: p.reason,
periods: p.periods,
entries: p.entries,
size: p.size,
})
.collect(),
keep_points: backup::KEEP_POINTS as u32,
keep_days: backup::KEEP_DAYS as u32,
}))
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct BackupCount {
/// Koleksiyon adı (`entries`, `periods`, …)
kind: String,
/// Türkçe ad
label: String,
#[ts(type = "number")]
backup: i64,
#[ts(type = "number")]
current: i64,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct BackupPreview {
source_name: String,
same_household: bool,
/// RFC 3339
exported_at: String,
app_version: String,
counts: Vec<BackupCount>,
/// Yedekteki dönemlerin ilk ve son günü
first_date: Option<String>,
last_date: Option<String>,
/// Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt
#[ts(type = "number")]
changed_since: i64,
warnings: Vec<String>,
}
pub async fn preview(
State(s): State<AppState>,
auth: AuthUser,
body: Bytes,
) -> AppResult<Json<BackupPreview>> {
auth.require_owner()?;
let snap = backup::parse(&body)?;
let p = backup::preview(&s.db, auth.household_id, &snap).await?;
Ok(Json(BackupPreview {
source_name: p.source_name,
same_household: p.same_household,
exported_at: iso(p.exported_at),
app_version: p.app_version,
counts: p
.counts
.into_iter()
.map(|c| BackupCount {
kind: c.kind.into(),
label: c.label.into(),
backup: c.backup,
current: c.current,
})
.collect(),
first_date: p.first_date,
last_date: p.last_date,
changed_since: p.changed_since,
warnings: p.warnings,
}))
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct RestoreResult {
/// İşlemden önceki durumun geri dönüş noktası ("Geri al" bununla)
restore_point_id: String,
}
pub async fn restore(
State(s): State<AppState>,
auth: AuthUser,
body: Bytes,
) -> AppResult<Json<RestoreResult>> {
auth.require_owner()?;
let snap = backup::parse(&body)?;
let id = backup::restore(
&s.db,
auth.household_id,
auth.user_id,
&snap,
Reason::BeforeRestore,
)
.await?;
Ok(Json(RestoreResult {
restore_point_id: id.to_string(),
}))
}
pub async fn undo(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<RestoreResult>> {
auth.require_owner()?;
let id = backup::undo(&s.db, auth.household_id, auth.user_id, parse_id(&id)?).await?;
Ok(Json(RestoreResult {
restore_point_id: id.to_string(),
}))
}
+17
View File
@@ -2,6 +2,7 @@
mod asset;
mod auth;
mod backup;
mod bank_import;
mod budget;
mod catalog;
@@ -92,6 +93,17 @@ pub fn router(state: AppState) -> Router {
.route("/periods/{id}/daily", get(stats::daily))
.route("/stats/periods", get(stats::periods))
.route("/reports/breakdown", get(reports::breakdown))
.route("/backup/download", get(backup::download))
.route("/backup/status", get(backup::status))
.route(
"/backup/preview",
post(backup::preview).layer(DefaultBodyLimit::max(backup_limit())),
)
.route(
"/backup/restore",
post(backup::restore).layer(DefaultBodyLimit::max(backup_limit())),
)
.route("/backup/restore-points/{id}/restore", post(backup::undo))
.route("/export/entries.csv", get(transfer::export_entries))
.route("/export/plan-items.csv", get(transfer::export_plan_items))
.route(
@@ -144,3 +156,8 @@ pub fn router(state: AppState) -> Router {
Router::new().nest("/api", api).with_state(state)
}
/// JSON yedeği gövde sınırı (`services::backup::MAX_BYTES`)
fn backup_limit() -> usize {
crate::services::backup::MAX_BYTES
}