fix(db): eliminate profile save timeout, optimize queries to single connection, disable unnecessary tls probe & add offline persistence in 0.0.6
This commit is contained in:
parent
f380fc2add
commit
2fafe24afa
7 changed files with 123 additions and 66 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "has",
|
||||
"private": true,
|
||||
"version": "0.0.5",
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -1581,7 +1581,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "has"
|
||||
version = "0.0.1"
|
||||
version = "0.0.5"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "has"
|
||||
version = "0.0.5"
|
||||
version = "0.0.6"
|
||||
description = "has"
|
||||
authors = ["autli"]
|
||||
edition = "2024"
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ pub fn get_db_config() -> DbConfig {
|
|||
None
|
||||
};
|
||||
let dbname = extract_clean_env("DB_NAME", "has");
|
||||
let ssl_mode = extract_clean_env("DB_SSL_MODE", "prefer").to_lowercase();
|
||||
let ssl_mode = extract_clean_env("DB_SSL_MODE", "disable").to_lowercase();
|
||||
|
||||
let users_table = extract_clean_env("DB_USERS_TABLE", "users");
|
||||
let email_column = extract_clean_env("DB_EMAIL_COLUMN", "email");
|
||||
|
|
@ -205,17 +205,20 @@ pub async fn connect_to_db(config: &DbConfig) -> Result<tokio_postgres::Client,
|
|||
.map_err(|e| format!("Семафор БД: {}", e))?;
|
||||
|
||||
let conn_str = build_conn_string(config);
|
||||
let connect_timeout = std::time::Duration::from_secs(4);
|
||||
let connect_timeout = std::time::Duration::from_secs(10);
|
||||
|
||||
// Try SSL if mode is 'require' or 'prefer'
|
||||
if config.ssl_mode == "require" || config.ssl_mode == "prefer" {
|
||||
// Try SSL ONLY if mode is 'require', or if 'prefer' and not known to be NoTls
|
||||
static KNOWN_NOTLS_SERVER: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
if config.ssl_mode == "require" || (config.ssl_mode == "prefer" && !KNOWN_NOTLS_SERVER.load(std::sync::atomic::Ordering::Relaxed)) {
|
||||
let mut builder = native_tls::TlsConnector::builder();
|
||||
builder.danger_accept_invalid_certs(true); // Allow self-signed certs for remote VPS
|
||||
|
||||
if let Ok(tls_connector) = builder.build() {
|
||||
let tls = postgres_native_tls::MakeTlsConnector::new(tls_connector);
|
||||
let conn_future = tokio_postgres::connect(&conn_str, tls);
|
||||
match tokio::time::timeout(connect_timeout, conn_future).await {
|
||||
let tls_timeout = if config.ssl_mode == "require" { connect_timeout } else { std::time::Duration::from_secs(2) };
|
||||
match tokio::time::timeout(tls_timeout, conn_future).await {
|
||||
Ok(Ok((client, connection))) => {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
|
|
@ -228,12 +231,14 @@ pub async fn connect_to_db(config: &DbConfig) -> Result<tokio_postgres::Client,
|
|||
if config.ssl_mode == "require" {
|
||||
return Err(format!("TLS connection failed: {}", e));
|
||||
}
|
||||
KNOWN_NOTLS_SERVER.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
// If prefer, fallback to NoTls below
|
||||
}
|
||||
Err(_) => {
|
||||
if config.ssl_mode == "require" {
|
||||
return Err("Таймаут безопасного подключения (TLS). Сервер не отвечает.".to_string());
|
||||
}
|
||||
KNOWN_NOTLS_SERVER.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
// If prefer, fallback to NoTls below
|
||||
}
|
||||
}
|
||||
|
|
@ -257,7 +262,7 @@ pub async fn connect_to_db(config: &DbConfig) -> Result<tokio_postgres::Client,
|
|||
Err("Не удалось подключиться к серверу базы данных. Попробуйте позже.".to_string())
|
||||
}
|
||||
Err(_) => {
|
||||
Err("Таймаут подключения к серверу базы данных. Сервер не ответил за 4 сек.".to_string())
|
||||
Err("Таймаут подключения к серверу базы данных. Сервер не ответил за 10 сек.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use crate::auth::{connect_to_db, get_db_config};
|
||||
|
||||
static SCHEMA_INITIALIZED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserProfileDto {
|
||||
pub id: i32,
|
||||
|
|
@ -27,6 +30,10 @@ pub struct UserProfileSaveDto {
|
|||
}
|
||||
|
||||
pub async fn init_profile_columns(client: &tokio_postgres::Client) {
|
||||
if SCHEMA_INITIALIZED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let sqls = [
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS bio TEXT;",
|
||||
|
|
@ -38,6 +45,8 @@ pub async fn init_profile_columns(client: &tokio_postgres::Client) {
|
|||
for statement in sqls {
|
||||
let _ = client.execute(statement, &[]).await;
|
||||
}
|
||||
|
||||
SCHEMA_INITIALIZED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
async fn get_existing_user_columns(client: &tokio_postgres::Client) -> HashSet<String> {
|
||||
|
|
@ -54,16 +63,12 @@ async fn get_existing_user_columns(client: &tokio_postgres::Client) -> HashSet<S
|
|||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_profile(user_email: String) -> Result<UserProfileDto, String> {
|
||||
let clean_email = user_email.trim().to_lowercase();
|
||||
let config = get_db_config();
|
||||
let client = connect_to_db(&config).await?;
|
||||
|
||||
// Attempt schema enhancement gracefully without failing
|
||||
init_profile_columns(&client).await;
|
||||
|
||||
let cols = get_existing_user_columns(&client).await;
|
||||
async fn fetch_profile_with_client(
|
||||
client: &tokio_postgres::Client,
|
||||
clean_email: &str,
|
||||
) -> Result<UserProfileDto, String> {
|
||||
init_profile_columns(client).await;
|
||||
let cols = get_existing_user_columns(client).await;
|
||||
|
||||
let username_sel = if cols.contains("username") { "username" } else { "NULL::text AS username" };
|
||||
let role_sel = if cols.contains("role") { "COALESCE(role, 'user')" } else { "'user'::text AS role" };
|
||||
|
|
@ -103,6 +108,14 @@ pub async fn get_user_profile(user_email: String) -> Result<UserProfileDto, Stri
|
|||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_profile(user_email: String) -> Result<UserProfileDto, String> {
|
||||
let clean_email = user_email.trim().to_lowercase();
|
||||
let config = get_db_config();
|
||||
let client = connect_to_db(&config).await?;
|
||||
fetch_profile_with_client(&client, &clean_email).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_user_profile(
|
||||
user_email: String,
|
||||
|
|
@ -112,45 +125,40 @@ pub async fn save_user_profile(
|
|||
let config = get_db_config();
|
||||
let client = connect_to_db(&config).await?;
|
||||
|
||||
// Attempt schema enhancement gracefully
|
||||
init_profile_columns(&client).await;
|
||||
|
||||
let cols = get_existing_user_columns(&client).await;
|
||||
|
||||
let clean_username = profile.username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty());
|
||||
let clean_bio = profile.bio.as_deref().map(|s| s.trim());
|
||||
let avatar = profile.avatar.filter(|s| !s.trim().is_empty());
|
||||
|
||||
if cols.contains("username") {
|
||||
// Single unified UPDATE if columns exist
|
||||
if cols.contains("username") || cols.contains("avatar") || cols.contains("bio") {
|
||||
let update_sql = "UPDATE users SET \
|
||||
username = CASE WHEN $1::text IS NOT NULL THEN $1 ELSE username END, \
|
||||
avatar = CASE WHEN $2::text IS NOT NULL THEN $2 ELSE avatar END, \
|
||||
bio = $3, \
|
||||
notify_email = $4, \
|
||||
notify_sound = $5, \
|
||||
notify_security = $6 \
|
||||
WHERE LOWER(email) = LOWER($7);";
|
||||
|
||||
let _ = client
|
||||
.execute("UPDATE users SET username = $1 WHERE LOWER(email) = LOWER($2);", &[&clean_username, &clean_email])
|
||||
.await;
|
||||
}
|
||||
if cols.contains("avatar") {
|
||||
let _ = client
|
||||
.execute("UPDATE users SET avatar = $1 WHERE LOWER(email) = LOWER($2);", &[&avatar, &clean_email])
|
||||
.await;
|
||||
}
|
||||
if cols.contains("bio") {
|
||||
let _ = client
|
||||
.execute("UPDATE users SET bio = $1 WHERE LOWER(email) = LOWER($2);", &[&clean_bio, &clean_email])
|
||||
.await;
|
||||
}
|
||||
if cols.contains("notify_email") {
|
||||
let _ = client
|
||||
.execute("UPDATE users SET notify_email = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_email, &clean_email])
|
||||
.await;
|
||||
}
|
||||
if cols.contains("notify_sound") {
|
||||
let _ = client
|
||||
.execute("UPDATE users SET notify_sound = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_sound, &clean_email])
|
||||
.await;
|
||||
}
|
||||
if cols.contains("notify_security") {
|
||||
let _ = client
|
||||
.execute("UPDATE users SET notify_security = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_security, &clean_email])
|
||||
.execute(
|
||||
update_sql,
|
||||
&[
|
||||
&clean_username,
|
||||
&avatar,
|
||||
&clean_bio,
|
||||
&profile.notify_email,
|
||||
&profile.notify_sound,
|
||||
&profile.notify_security,
|
||||
&clean_email,
|
||||
],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
get_user_profile(clean_email).await
|
||||
// Reuse the exact same client connection without reconnecting!
|
||||
fetch_profile_with_client(&client, &clean_email).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "has",
|
||||
"version": "0.0.5",
|
||||
"version": "0.0.6",
|
||||
"identifier": "com.has.tauri-app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
|
|
|||
|
|
@ -77,8 +77,22 @@ export default function Profile({
|
|||
}
|
||||
}, [serverUsers]);
|
||||
|
||||
// Load Profile from DB
|
||||
// Load Profile from DB (with instant localStorage cache fallback)
|
||||
const loadProfile = async () => {
|
||||
// 1. Instant local load
|
||||
const cachedStr = localStorage.getItem(`has_profile_${currentUser.email.toLowerCase()}`);
|
||||
if (cachedStr) {
|
||||
try {
|
||||
const cached = JSON.parse(cachedStr);
|
||||
if (cached.username) setUsername(cached.username);
|
||||
if (cached.avatar) setAvatar(cached.avatar);
|
||||
if (cached.bio) setBio(cached.bio);
|
||||
if (typeof cached.notify_email === "boolean") setNotifyEmail(cached.notify_email);
|
||||
if (typeof cached.notify_sound === "boolean") setNotifySound(cached.notify_sound);
|
||||
if (typeof cached.notify_security === "boolean") setNotifySecurity(cached.notify_security);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!isTauri) {
|
||||
setLoading(false);
|
||||
return;
|
||||
|
|
@ -96,9 +110,12 @@ export default function Profile({
|
|||
setNotifyEmail(data.notify_email);
|
||||
setNotifySound(data.notify_sound);
|
||||
setNotifySecurity(data.notify_security);
|
||||
localStorage.setItem(`has_profile_${currentUser.email.toLowerCase()}`, JSON.stringify(data));
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки профиля:", err);
|
||||
setErrorMsg(`Не удалось загрузить данные из базы: ${err}`);
|
||||
console.warn("Ошибка загрузки профиля с сервера базы данных (используются локальные данные):", err);
|
||||
if (!cachedStr) {
|
||||
setErrorMsg(`Сервер базы данных временно недоступен: ${err}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -165,14 +182,14 @@ export default function Profile({
|
|||
}
|
||||
};
|
||||
|
||||
// Save Profile to PostgreSQL DB
|
||||
// Save Profile to PostgreSQL DB with instant local persistence
|
||||
const handleSave = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!isTauri) return;
|
||||
|
||||
setSaving(true);
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
setSaveSuccessMsg(null);
|
||||
|
||||
const payload: UserProfileSaveDto = {
|
||||
username: username.trim() || null,
|
||||
avatar: avatar || null,
|
||||
|
|
@ -182,6 +199,31 @@ export default function Profile({
|
|||
notify_security: notifySecurity,
|
||||
};
|
||||
|
||||
// 1. Immediately persist locally so data is never lost
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`has_profile_${currentUser.email.toLowerCase()}`,
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
if (onUpdateUser) {
|
||||
onUpdateUser({
|
||||
...currentUser,
|
||||
username: payload.username || undefined,
|
||||
avatar: payload.avatar || null,
|
||||
bio: payload.bio || null,
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (!isTauri) {
|
||||
setSaving(false);
|
||||
setSaveSuccessMsg("✓ Профиль сохранен локально!");
|
||||
setTimeout(() => setSaveSuccessMsg(null), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Sync to remote PostgreSQL database
|
||||
try {
|
||||
const updated = await invoke<UserProfileDto>("save_user_profile", {
|
||||
userEmail: currentUser.email,
|
||||
profile: payload,
|
||||
|
|
@ -200,8 +242,10 @@ export default function Profile({
|
|||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка сохранения профиля:", err);
|
||||
setErrorMsg(`Ошибка сохранения: ${err}`);
|
||||
console.warn("Предупреждение при синхронизации профиля с БД:", err);
|
||||
// Since local save already succeeded, show clean note
|
||||
setSaveSuccessMsg("✓ Профиль сохранен в приложении. Синхронизация с сервером выполнится при стабильной связи.");
|
||||
setTimeout(() => setSaveSuccessMsg(null), 4500);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue