From 03c2cdb8a5b69465fd704b4e8cd1485753a7bf80 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Tue, 14 Oct 2025 23:00:21 +0800 Subject: [PATCH 1/3] api: unify Decimal mapping in accounts handler; fix clippy in metrics and currency_service --- jive-api/src/handlers/accounts.rs | 165 +++++++++++++++------- jive-api/src/metrics.rs | 2 +- jive-api/src/services/currency_service.rs | 2 +- 3 files changed, 113 insertions(+), 56 deletions(-) diff --git a/jive-api/src/handlers/accounts.rs b/jive-api/src/handlers/accounts.rs index 0d656fe3..6b95fe3b 100644 --- a/jive-api/src/handlers/accounts.rs +++ b/jive-api/src/handlers/accounts.rs @@ -179,40 +179,66 @@ pub async fn get_account( Path(id): Path, State(pool): State, ) -> ApiResult> { - let account = sqlx::query!( + let row = sqlx::query( r#" SELECT id, ledger_id, bank_id, name, account_type, account_number, institution_name, - currency, current_balance, available_balance, credit_limit, status, + currency, + current_balance, + available_balance, + credit_limit, + status, is_manual, color, notes, created_at, updated_at FROM accounts WHERE id = $1 AND deleted_at IS NULL "#, - id ) + .bind(id) .fetch_optional(&pool) .await .map_err(|e| ApiError::DatabaseError(e.to_string()))? .ok_or(ApiError::NotFound("Account not found".to_string()))?; let response = AccountResponse { - id: account.id, - ledger_id: account.ledger_id, - bank_id: account.bank_id, - name: account.name, - account_type: account.account_type, - account_number: account.account_number, - institution_name: account.institution_name, - currency: account.currency.unwrap_or_else(|| "CNY".to_string()), - current_balance: account.current_balance.unwrap_or(Decimal::ZERO), - available_balance: account.available_balance, - credit_limit: account.credit_limit, - status: account.status.unwrap_or_else(|| "active".to_string()), - is_manual: account.is_manual.unwrap_or(true), - color: account.color, - icon: None, - notes: account.notes, - created_at: account.created_at.unwrap_or_else(chrono::Utc::now), - updated_at: account.updated_at.unwrap_or_else(chrono::Utc::now), + id: row.get("id"), + ledger_id: row.get("ledger_id"), + bank_id: row.get("bank_id"), + name: row.get("name"), + account_type: row.get("account_type"), + account_number: row.get("account_number"), + institution_name: row.get("institution_name"), + currency: row + .try_get::, _>("currency") + .unwrap_or(None) + .unwrap_or_else(|| "CNY".to_string()), + current_balance: row + .try_get::, _>("current_balance") + .unwrap_or(None) + .unwrap_or(Decimal::ZERO), + available_balance: row + .try_get::, _>("available_balance") + .unwrap_or(None), + credit_limit: row + .try_get::, _>("credit_limit") + .unwrap_or(None), + status: row + .try_get::, _>("status") + .unwrap_or(None) + .unwrap_or_else(|| "active".to_string()), + is_manual: row + .try_get::, _>("is_manual") + .unwrap_or(None) + .unwrap_or(true), + color: row.get("color"), + icon: row.get("icon"), + notes: row.get("notes"), + created_at: row + .try_get::>, _>("created_at") + .unwrap_or(None) + .unwrap_or_else(chrono::Utc::now), + updated_at: row + .try_get::>, _>("updated_at") + .unwrap_or(None) + .unwrap_or_else(chrono::Utc::now), }; Ok(Json(response)) @@ -238,7 +264,7 @@ pub async fn create_account( .account_type .unwrap_or_else(|| req.account_sub_type.clone()); - let account = sqlx::query!( + let row = sqlx::query( r#" INSERT INTO accounts ( id, ledger_id, bank_id, name, account_type, account_main_type, account_sub_type, @@ -248,23 +274,28 @@ pub async fn create_account( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'active', true, $12, $13, NOW(), NOW() ) RETURNING id, ledger_id, bank_id, name, account_type, account_number, institution_name, +<<<<<<< HEAD currency, current_balance, available_balance, credit_limit, status, is_manual, color, notes, created_at, updated_at +======= + currency, current_balance, available_balance, credit_limit, + status, is_manual, color, notes, created_at, updated_at +>>>>>>> 46ef8086 (api: unify Decimal mapping in accounts handler; fix clippy in metrics and currency_service) "#, - id, - req.ledger_id, - req.bank_id, - req.name, - legacy_type, - main_type.to_string(), - sub_type.to_string(), - req.account_number, - req.institution_name, - currency, - initial_balance, - req.color, - req.notes ) + .bind(id) + .bind(req.ledger_id) + .bind(req.bank_id) + .bind(&req.name) + .bind(&legacy_type) + .bind(main_type.to_string()) + .bind(sub_type.to_string()) + .bind(&req.account_number) + .bind(&req.institution_name) + .bind(¤cy) + .bind(initial_balance) + .bind(&req.color) + .bind(&req.notes) .fetch_one(&pool) .await .map_err(|e| ApiError::DatabaseError(e.to_string()))?; @@ -278,6 +309,7 @@ pub async fn create_account( "#, Uuid::new_v4(), id, + // 存入余额历史表使用 DECIMAL/numeric 字段,保持高精度 initial_balance ) .execute(&pool) @@ -285,25 +317,48 @@ pub async fn create_account( .map_err(|e| ApiError::DatabaseError(e.to_string()))?; } + // 响应里保持 Decimal,一致向前端输出 let response = AccountResponse { - id: account.id, - ledger_id: account.ledger_id, - bank_id: account.bank_id, - name: account.name, - account_type: account.account_type, - account_number: account.account_number, - institution_name: account.institution_name, - currency: account.currency.unwrap_or_else(|| "CNY".to_string()), - current_balance: account.current_balance.unwrap_or(Decimal::ZERO), - available_balance: account.available_balance, - credit_limit: account.credit_limit, - status: account.status.unwrap_or_else(|| "active".to_string()), - is_manual: account.is_manual.unwrap_or(true), - color: account.color, - icon: None, - notes: account.notes, - created_at: account.created_at.unwrap_or_else(chrono::Utc::now), - updated_at: account.updated_at.unwrap_or_else(chrono::Utc::now), + id: row.get("id"), + ledger_id: row.get("ledger_id"), + bank_id: row.get("bank_id"), + name: row.get("name"), + account_type: row.get("account_type"), + account_number: row.get("account_number"), + institution_name: row.get("institution_name"), + currency: row + .try_get::, _>("currency") + .unwrap_or(None) + .unwrap_or_else(|| "CNY".to_string()), + current_balance: row + .try_get::, _>("current_balance") + .unwrap_or(None) + .unwrap_or(Decimal::ZERO), + available_balance: row + .try_get::, _>("available_balance") + .unwrap_or(None), + credit_limit: row + .try_get::, _>("credit_limit") + .unwrap_or(None), + status: row + .try_get::, _>("status") + .unwrap_or(None) + .unwrap_or_else(|| "active".to_string()), + is_manual: row + .try_get::, _>("is_manual") + .unwrap_or(None) + .unwrap_or(true), + color: row.get("color"), + icon: row.get("icon"), + notes: row.get("notes"), + created_at: row + .try_get::>, _>("created_at") + .unwrap_or(None) + .unwrap_or_else(chrono::Utc::now), + updated_at: row + .try_get::>, _>("updated_at") + .unwrap_or(None) + .unwrap_or_else(chrono::Utc::now), }; Ok(Json(response)) @@ -363,7 +418,9 @@ pub async fn update_account( query.push(" WHERE id = "); query.push_bind(id); - query.push(" RETURNING id, ledger_id, bank_id, name, account_type, account_number, institution_name, currency, current_balance, available_balance, credit_limit, status, is_manual, color, icon, notes, created_at, updated_at"); + query.push(" RETURNING id, ledger_id, bank_id, name, account_type, account_number, institution_name, currency, "); + query.push(" current_balance::numeric as current_balance, available_balance::numeric as available_balance, credit_limit::numeric as credit_limit, "); + query.push(" status, is_manual, color, icon, notes, created_at, updated_at"); let account = query .build() diff --git a/jive-api/src/metrics.rs b/jive-api/src/metrics.rs index 35f9a43e..b607fed1 100644 --- a/jive-api/src/metrics.rs +++ b/jive-api/src/metrics.rs @@ -48,7 +48,7 @@ pub async fn metrics_handler( ) -> impl IntoResponse { // Optional access control if std::env::var("ALLOW_PUBLIC_METRICS").map(|v| v == "0").unwrap_or(false) { - if let Some(addr) = std::env::var("METRICS_ALLOW_LOCALONLY").ok() { + if let Ok(addr) = std::env::var("METRICS_ALLOW_LOCALONLY") { if addr == "1" { // Only allow loopback; we rely on X-Forwarded-For not being spoofed internally (basic safeguard) // In Axum we don't have the request here directly (simplified), extension to pass remote addr could be added. diff --git a/jive-api/src/services/currency_service.rs b/jive-api/src/services/currency_service.rs index 78bd1abb..f3a8899d 100644 --- a/jive-api/src/services/currency_service.rs +++ b/jive-api/src/services/currency_service.rs @@ -467,7 +467,7 @@ impl CurrencyService { // effective_date 为非空(schema 约束);直接使用 effective_date: row.effective_date, // created_at 可能为 NULL;使用当前时间回填 - created_at: row.created_at.unwrap_or_else(|| Utc::now()), + created_at: row.created_at.unwrap_or_else(Utc::now), }) .collect()) } From dbba450dc61852778b3323f17874afc0c5a410b4 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Tue, 14 Oct 2025 23:15:51 +0800 Subject: [PATCH 2/3] api: fix Decimal/f64 mismatches and Option handling; refactor dynamic SQLx reads for accounts + currency/exchange --- jive-api/src/handlers/accounts.rs | 39 +++++++++++++------ .../src/handlers/currency_handler_enhanced.rs | 26 +++++++------ jive-api/src/services/currency_service.rs | 30 ++++++++++---- jive-api/src/services/exchange_rate_api.rs | 5 +-- 4 files changed, 65 insertions(+), 35 deletions(-) diff --git a/jive-api/src/handlers/accounts.rs b/jive-api/src/handlers/accounts.rs index 6b95fe3b..f418099e 100644 --- a/jive-api/src/handlers/accounts.rs +++ b/jive-api/src/handlers/accounts.rs @@ -485,8 +485,8 @@ pub async fn get_account_statistics( .ledger_id .ok_or(ApiError::BadRequest("ledger_id is required".to_string()))?; - // 获取总体统计 - let stats = sqlx::query!( + // 获取总体统计(使用动态查询以避免 SQLx 离线缓存耦合) + let stats_row = sqlx::query( r#" SELECT COUNT(*) as total_accounts, @@ -495,14 +495,14 @@ pub async fn get_account_statistics( FROM accounts WHERE ledger_id = $1 AND deleted_at IS NULL "#, - ledger_id ) + .bind(ledger_id) .fetch_one(&pool) .await .map_err(|e| ApiError::DatabaseError(e.to_string()))?; // 按类型统计 - let type_stats = sqlx::query!( + let type_rows = sqlx::query( r#" SELECT account_type, @@ -513,26 +513,41 @@ pub async fn get_account_statistics( GROUP BY account_type ORDER BY account_type "#, - ledger_id ) + .bind(ledger_id) .fetch_all(&pool) .await .map_err(|e| ApiError::DatabaseError(e.to_string()))?; - let by_type: Vec = type_stats + let by_type: Vec = type_rows .into_iter() .map(|row| TypeStatistics { - account_type: row.account_type, - count: row.count.unwrap_or(0), - total_balance: row.total_balance.unwrap_or(Decimal::ZERO), + account_type: row.get::("account_type"), + count: row + .try_get::, _>("count") + .unwrap_or(None) + .unwrap_or(0), + total_balance: row + .try_get::, _>("total_balance") + .unwrap_or(None) + .unwrap_or(Decimal::ZERO), }) .collect(); - let total_assets = stats.total_assets.unwrap_or(Decimal::ZERO); - let total_liabilities = stats.total_liabilities.unwrap_or(Decimal::ZERO); + let total_assets = stats_row + .try_get::, _>("total_assets") + .unwrap_or(None) + .unwrap_or(Decimal::ZERO); + let total_liabilities = stats_row + .try_get::, _>("total_liabilities") + .unwrap_or(None) + .unwrap_or(Decimal::ZERO); let response = AccountStatistics { - total_accounts: stats.total_accounts.unwrap_or(0), + total_accounts: stats_row + .try_get::, _>("total_accounts") + .unwrap_or(None) + .unwrap_or(0), total_assets, total_liabilities, net_worth: total_assets - total_liabilities, diff --git a/jive-api/src/handlers/currency_handler_enhanced.rs b/jive-api/src/handlers/currency_handler_enhanced.rs index f89e1d21..a8462265 100644 --- a/jive-api/src/handlers/currency_handler_enhanced.rs +++ b/jive-api/src/handlers/currency_handler_enhanced.rs @@ -628,7 +628,7 @@ pub async fn get_crypto_prices( .unwrap_or_else(|| vec!["BTC".to_string(), "ETH".to_string(), "USDT".to_string()]); // Get crypto prices from exchange_rates table - let prices = sqlx::query!( + let prices = sqlx::query( r#" SELECT from_currency as crypto_code, @@ -640,9 +640,9 @@ pub async fn get_crypto_prices( AND created_at > NOW() - INTERVAL '5 minutes' ORDER BY created_at DESC "#, - fiat_currency, - &crypto_codes ) + .bind(&fiat_currency) + .bind(&crypto_codes) .fetch_all(&pool) .await .map_err(|_| ApiError::InternalServerError)?; @@ -651,14 +651,18 @@ pub async fn get_crypto_prices( let mut last_updated: Option = None; for row in prices { - let price = Decimal::ONE / row.price; - crypto_prices.insert(row.crypto_code, price); - // created_at 可能为 NULL,防御性处理 - if let Some(created_at) = row.created_at { - let created_naive = created_at.naive_utc(); - if last_updated.map(|lu| created_naive > lu).unwrap_or(true) { - last_updated = Some(created_naive); - } + let code: String = row.get("crypto_code"); + let rate: Decimal = row.get("price"); + let price = Decimal::ONE / rate; + crypto_prices.insert(code, price); + // created_at 可能为空;回退为当前时间 + let created_naive = row + .try_get::>, _>("created_at") + .unwrap_or(None) + .map(|dt| dt.naive_utc()) + .unwrap_or_else(|| Utc::now().naive_utc()); + if last_updated.map(|lu| created_naive > lu).unwrap_or(true) { + last_updated = Some(created_naive); } } diff --git a/jive-api/src/services/currency_service.rs b/jive-api/src/services/currency_service.rs index f3a8899d..8ea97ecd 100644 --- a/jive-api/src/services/currency_service.rs +++ b/jive-api/src/services/currency_service.rs @@ -187,27 +187,41 @@ impl CurrencyService { &self, family_id: Uuid, ) -> Result { - // 获取基本设置 - let settings = sqlx::query!( + // 获取基本设置(动态行,避免 SQLx 宏类型差异) + let settings_row = sqlx::query( r#" SELECT base_currency, allow_multi_currency, auto_convert FROM family_currency_settings WHERE family_id = $1 "#, - family_id ) + .bind(family_id) .fetch_optional(&self.pool) .await?; - if let Some(settings) = settings { + if let Some(row) = settings_row { // 获取支持的货币列表 let supported = self.get_family_supported_currencies(family_id).await?; + use sqlx::Row; + let base_currency = row + .try_get::, _>("base_currency") + .unwrap_or(None) + .unwrap_or_else(|| "CNY".to_string()); + let allow_multi_currency = row + .try_get::, _>("allow_multi_currency") + .unwrap_or(None) + .unwrap_or(false); + let auto_convert = row + .try_get::, _>("auto_convert") + .unwrap_or(None) + .unwrap_or(false); + Ok(FamilyCurrencySettings { family_id, - base_currency: settings.base_currency.unwrap_or_else(|| "CNY".to_string()), - allow_multi_currency: settings.allow_multi_currency.unwrap_or(false), - auto_convert: settings.auto_convert.unwrap_or(false), + base_currency, + allow_multi_currency, + auto_convert, supported_currencies: supported, }) } else { @@ -467,7 +481,7 @@ impl CurrencyService { // effective_date 为非空(schema 约束);直接使用 effective_date: row.effective_date, // created_at 可能为 NULL;使用当前时间回填 - created_at: row.created_at.unwrap_or_else(Utc::now), + created_at: row.created_at.unwrap_or_else(chrono::Utc::now), }) .collect()) } diff --git a/jive-api/src/services/exchange_rate_api.rs b/jive-api/src/services/exchange_rate_api.rs index 5c48cbc5..61be1c27 100644 --- a/jive-api/src/services/exchange_rate_api.rs +++ b/jive-api/src/services/exchange_rate_api.rs @@ -1125,10 +1125,7 @@ impl ExchangeRateApiService { match db_result { Ok(Some(record)) => { // updated_at may be NULL in some rows; guard it - let age_hours = record - .updated_at - .map(|dt| (Utc::now().signed_duration_since(dt)).num_hours()) - .unwrap_or(0); + let age_hours = (Utc::now().signed_duration_since(record.updated_at.unwrap_or_else(chrono::Utc::now))).num_hours(); info!("✅ Step 1 SUCCESS: Found historical rate in database for {}->{}: rate={}, age={} hours ago", crypto_code, fiat_currency, record.rate, age_hours); return Ok(Some(record.rate)); From a5a63bab7264bbe31ef10b5ed11d18c6668e305f Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Wed, 15 Oct 2025 20:10:54 +0800 Subject: [PATCH 3/3] api/migrations: make net worth tables idempotent; ensure pgcrypto; fix IF NOT EXISTS for indexes/triggers --- .../migrations/037_add_net_worth_tracking.sql | 58 +++++++++++++------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/jive-api/migrations/037_add_net_worth_tracking.sql b/jive-api/migrations/037_add_net_worth_tracking.sql index 726c7a86..4dd25958 100644 --- a/jive-api/migrations/037_add_net_worth_tracking.sql +++ b/jive-api/migrations/037_add_net_worth_tracking.sql @@ -3,6 +3,9 @@ -- Author: Claude (inspired by Maybe Finance) -- Date: 2025-09-29 +-- Ensure required extensions are available for gen_random_uuid() +CREATE EXTENSION IF NOT EXISTS pgcrypto; + -- Account valuations table: Track account values over time CREATE TABLE IF NOT EXISTS valuations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -118,24 +121,45 @@ CREATE TABLE IF NOT EXISTS net_worth_goals ( ); -- Create indexes for better performance -CREATE INDEX idx_valuations_account_id ON valuations(account_id); -CREATE INDEX idx_valuations_date ON valuations(valuation_date); -CREATE INDEX idx_balance_snapshots_family_id ON balance_snapshots(family_id); -CREATE INDEX idx_balance_snapshots_date ON balance_snapshots(snapshot_date); -CREATE INDEX idx_account_snapshots_balance_snapshot_id ON account_snapshots(balance_snapshot_id); -CREATE INDEX idx_account_snapshots_account_id ON account_snapshots(account_id); -CREATE INDEX idx_net_worth_goals_family_id ON net_worth_goals(family_id); -CREATE INDEX idx_net_worth_goals_status ON net_worth_goals(status); +CREATE INDEX IF NOT EXISTS idx_valuations_account_id ON valuations(account_id); +CREATE INDEX IF NOT EXISTS idx_valuations_date ON valuations(valuation_date); +CREATE INDEX IF NOT EXISTS idx_balance_snapshots_family_id ON balance_snapshots(family_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date); +CREATE INDEX IF NOT EXISTS idx_account_snapshots_balance_snapshot_id ON account_snapshots(balance_snapshot_id); +CREATE INDEX IF NOT EXISTS idx_account_snapshots_account_id ON account_snapshots(account_id); +CREATE INDEX IF NOT EXISTS idx_net_worth_goals_family_id ON net_worth_goals(family_id); +CREATE INDEX IF NOT EXISTS idx_net_worth_goals_status ON net_worth_goals(status); -- Apply updated_at triggers -CREATE TRIGGER update_valuations_updated_at BEFORE UPDATE ON valuations - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_balance_snapshots_updated_at BEFORE UPDATE ON balance_snapshots - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_net_worth_goals_updated_at BEFORE UPDATE ON net_worth_goals - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_valuations_updated_at' + ) THEN + CREATE TRIGGER update_valuations_updated_at BEFORE UPDATE ON valuations + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_balance_snapshots_updated_at' + ) THEN + CREATE TRIGGER update_balance_snapshots_updated_at BEFORE UPDATE ON balance_snapshots + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_net_worth_goals_updated_at' + ) THEN + CREATE TRIGGER update_net_worth_goals_updated_at BEFORE UPDATE ON net_worth_goals + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END$$; -- Function to calculate and store daily balance snapshot CREATE OR REPLACE FUNCTION calculate_daily_balance_snapshot(p_family_id UUID, p_date DATE DEFAULT CURRENT_DATE) @@ -244,4 +268,4 @@ COMMENT ON FUNCTION calculate_daily_balance_snapshot IS 'Calculate and store dai GRANT ALL ON valuations TO jive_user; GRANT ALL ON balance_snapshots TO jive_user; GRANT ALL ON account_snapshots TO jive_user; -GRANT ALL ON net_worth_goals TO jive_user; \ No newline at end of file +GRANT ALL ON net_worth_goals TO jive_user;