Skip to content
5 changes: 3 additions & 2 deletions jive-api/migrations/013_add_payee_id_to_transactions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
-- Ensure extension for uuid generation exists (if needed by payees references elsewhere)
-- CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Add column if missing
-- Add column if missing (nullable UUID, no FK constraint for now)
-- TODO: Add REFERENCES payees(id) constraint once payees table is created
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS payee_id UUID REFERENCES payees(id);
ADD COLUMN IF NOT EXISTS payee_id UUID;

-- Index for filtering by payee_id
CREATE INDEX IF NOT EXISTS idx_transactions_payee_id ON transactions(payee_id);
Expand Down
36 changes: 36 additions & 0 deletions jive-api/migrations/031_create_banks_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- 创建银行表,支持拼音搜索
CREATE TABLE banks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
name_cn VARCHAR(100),
name_en VARCHAR(200),
name_cn_pinyin VARCHAR(200),
name_cn_abbr VARCHAR(50),
icon_filename VARCHAR(100),
icon_url TEXT,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The icon_url column is defined in the banks table schema but appears to be unused. The API handler in jive-api/src/handlers/banks.rs does not select this column, and the Flutter client in jive-flutter/lib/models/bank.dart dynamically constructs the icon URL from icon_filename. To avoid confusion and redundant data storage, this column should be removed.

is_crypto BOOLEAN DEFAULT FALSE,
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_banks_code ON banks(code);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The index idx_banks_code on the code column is redundant. PostgreSQL automatically creates an index for columns with a UNIQUE constraint, which the code column has. Removing this explicit CREATE INDEX statement will simplify the migration script without affecting performance.

CREATE INDEX idx_banks_name_cn ON banks(name_cn);
CREATE INDEX idx_banks_pinyin ON banks(name_cn_pinyin);
CREATE INDEX idx_banks_abbr ON banks(name_cn_abbr);
CREATE INDEX idx_banks_is_active ON banks(is_active);
CREATE INDEX idx_banks_sort_order ON banks(sort_order DESC, name_cn);

COMMENT ON TABLE banks IS '银行和金融机构表';
COMMENT ON COLUMN banks.code IS '唯一标识码(从icon文件名提取)';
COMMENT ON COLUMN banks.name IS '银行名称(主名称)';
COMMENT ON COLUMN banks.name_cn IS '中文名称';
COMMENT ON COLUMN banks.name_en IS '英文名称';
COMMENT ON COLUMN banks.name_cn_pinyin IS '中文全拼(用于拼音搜索)';
COMMENT ON COLUMN banks.name_cn_abbr IS '中文简拼(用于首字母搜索)';
COMMENT ON COLUMN banks.icon_filename IS '图标文件名';
COMMENT ON COLUMN banks.is_crypto IS '是否为加密货币';
COMMENT ON COLUMN banks.sort_order IS '排序权重(热门银行可设置更高值)';
COMMENT ON COLUMN banks.is_active IS '是否启用';
98 changes: 98 additions & 0 deletions jive-api/src/handlers/banks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use axum::{
extract::{Query, State},
response::Json,
};
use serde::Deserialize;
use serde::Serialize;
use sqlx::{PgPool, QueryBuilder, Row};

use crate::error::{ApiError, ApiResult};
use crate::models::bank::Bank;

#[derive(Debug, Deserialize)]
pub struct BankQuery {
pub search: Option<String>,
pub is_crypto: Option<bool>,
pub limit: Option<i64>,
}

pub async fn list_banks(
Query(params): Query<BankQuery>,
State(pool): State<PgPool>,
) -> ApiResult<Json<Vec<Bank>>> {
let mut query = QueryBuilder::new(
"SELECT id, code, name, name_cn, name_en, icon_filename, is_crypto
FROM banks WHERE is_active = true"
);

if let Some(search) = params.search {
query.push(" AND (");
query.push("name_cn ILIKE ");
query.push_bind(format!("%{}%", search));
query.push(" OR name ILIKE ");
query.push_bind(format!("%{}%", search));
query.push(" OR name_en ILIKE ");
query.push_bind(format!("%{}%", search));
query.push(" OR name_cn_pinyin ILIKE ");
query.push_bind(format!("%{}%", search));
query.push(" OR name_cn_abbr ILIKE ");
query.push_bind(format!("%{}%", search));
query.push(")");
}
Comment on lines +28 to +41

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for building the search query involves repeating query.push(...) and query.push_bind(...) for each searchable field. This can be made more maintainable and less repetitive by iterating over a list of column names.

    if let Some(search) = params.search {
        let search_pattern = format!("%{}%", search);
        let search_fields = ["name_cn", "name", "name_en", "name_cn_pinyin", "name_cn_abbr"];

        query.push(" AND (");

        for (i, field) in search_fields.iter().enumerate() {
            if i > 0 {
                query.push(" OR ");
            }
            query.push(format!("{} ILIKE ", field));
            query.push_bind(search_pattern.clone());
        }

        query.push(")");
    }


if let Some(is_crypto) = params.is_crypto {
query.push(" AND is_crypto = ");
query.push_bind(is_crypto);
}

query.push(" ORDER BY sort_order DESC, name_cn, name");
query.push(" LIMIT ");
query.push_bind(params.limit.unwrap_or(100));

let banks = query
.build()
.fetch_all(&pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;

let mut response = Vec::new();
for row in banks {
response.push(Bank {
id: row.get("id"),
code: row.get("code"),
name: row.get("name"),
name_cn: row.get("name_cn"),
name_en: row.get("name_en"),
icon_filename: row.get("icon_filename"),
is_crypto: row.get("is_crypto"),
});
}

Ok(Json(response))
Comment on lines +52 to +71

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation manually maps each PgRow to the Bank struct. This is verbose and error-prone, as any change in the SELECT statement or Bank struct requires manual updates in two places.

You can use sqlx's automatic mapping capabilities to make the code more concise, readable, and safer. To do this, you'll need to:

  1. Add #[derive(sqlx::FromRow)] to your Bank struct definition in jive-api/src/models/bank.rs.
  2. Use build_query_as() and let sqlx handle the mapping.

This change significantly improves maintainability and reduces the chance of runtime errors from mismatched columns.

    let banks: Vec<Bank> = query
        .build_query_as()
        .fetch_all(&pool)
        .await
        .map_err(|e| ApiError::DatabaseError(e.to_string()))?;

    Ok(Json(banks))

}

#[derive(Debug, Serialize)]
pub struct BanksVersionResponse {
pub version: String,
pub count: i64,
pub updated_at: chrono::DateTime<chrono::Utc>,
}

/// GET /api/v1/banks/version — return latest banks metadata (if present)
pub async fn get_banks_version(
State(pool): State<PgPool>,
) -> ApiResult<Json<BanksVersionResponse>> {
let row = sqlx::query(
r#"SELECT version, total_count, updated_at
FROM banks_metadata ORDER BY id DESC LIMIT 1"#,
)
.fetch_one(&pool)
.await
.map_err(|_| ApiError::NotFound("Banks metadata not found".into()))?;

Ok(Json(BanksVersionResponse {
version: row.get("version"),
count: row.get("total_count"),
updated_at: row.get("updated_at"),
}))
}
1 change: 1 addition & 0 deletions jive-api/src/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod template_handler;
pub mod accounts;
pub mod banks;
pub mod transactions;
pub mod payees;
pub mod rules;
Expand Down
14 changes: 12 additions & 2 deletions jive-api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use std::sync::Arc;
use tokio::net::TcpListener;
use tower::ServiceBuilder;
use tower_http::{
services::ServeDir,
trace::TraceLayer,
};
use tracing::{info, warn, error};
Expand All @@ -30,6 +31,7 @@ use jive_money_api::{handlers, services, ws};
// 导入处理器
use handlers::template_handler::*;
use handlers::accounts::*;
use handlers::banks;
use handlers::transactions::*;
use handlers::payees::*;
use handlers::rules::*;
Expand Down Expand Up @@ -247,7 +249,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/api/v1/accounts/:id", put(update_account))
.route("/api/v1/accounts/:id", delete(delete_account))
.route("/api/v1/accounts/statistics", get(get_account_statistics))


// 银行管理 API
.route("/api/v1/banks", get(banks::list_banks))
.route("/api/v1/banks/version", get(banks::get_banks_version))

// 交易管理 API
.route("/api/v1/transactions", get(list_transactions))
.route("/api/v1/transactions", post(create_transaction))
Expand All @@ -268,6 +274,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/api/v1/payees/suggestions", get(get_payee_suggestions))
.route("/api/v1/payees/statistics", get(get_payee_statistics))
.route("/api/v1/payees/merge", post(merge_payees))

// 静态资源:银行图标
.nest_service("/static/bank_icons", ServeDir::new("jive-api/static/bank_icons"))

// 规则引擎 API
.route("/api/v1/rules", get(list_rules))
Expand Down Expand Up @@ -373,7 +382,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/api/v1/categories/import", post(category_handler::batch_import_templates))

// 静态文件
.route("/static/icons/*path", get(serve_icon));
.route("/static/icons/*path", get(serve_icon))
.nest_service("/static/bank_icons", ServeDir::new("static/bank_icons"));

// 可选 Demo 占位符接口(按特性开关)
#[cfg(feature = "demo_endpoints")]
Expand Down
19 changes: 19 additions & 0 deletions jive-api/src/models/bank.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Serialize, Deserialize)]
pub struct Bank {
pub id: Uuid,
pub code: String,
pub name: String,
pub name_cn: Option<String>,
pub name_en: Option<String>,
pub icon_filename: Option<String>,
pub is_crypto: bool,
}

impl Bank {
pub fn display_name(&self) -> &str {
self.name_cn.as_deref().unwrap_or(&self.name)
}
}
4 changes: 4 additions & 0 deletions jive-api/src/models/mod.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
#![allow(dead_code)]

// pub mod account; // Temporarily commented - file not in this branch yet
pub mod audit;
pub mod bank;
pub mod family;
pub mod invitation;
pub mod membership;
pub mod permission;
pub mod transaction;

// #[allow(unused_imports)]
// pub use account::{AccountMainType, AccountSubType}; // Commented with module
#[allow(unused_imports)]
pub use audit::{AuditAction, AuditLog, AuditLogFilter, CreateAuditLogRequest};
#[allow(unused_imports)]
Expand Down
62 changes: 62 additions & 0 deletions jive-flutter/lib/models/bank.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
class Bank {
final String id;
final String code;
final String name;
final String? nameCn;
final String? nameEn;
final String? iconFilename;
final bool isCrypto;

Bank({
required this.id,
required this.code,
required this.name,
this.nameCn,
this.nameEn,
this.iconFilename,
this.isCrypto = false,
});

String get displayName => nameCn ?? name;

String? get iconUrl => iconFilename != null
? '/static/bank_icons/$iconFilename'
: null;
Comment on lines +22 to +24

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The iconUrl getter constructs a relative URL. When using this with Image.network, you'll need to prepend the API's base URL. To make this model more self-contained and easier to use across the app, consider making the base URL available here, perhaps through a static configuration class, and returning a full URL.


factory Bank.fromJson(Map<String, dynamic> json) {
return Bank(
id: json['id'] as String,
code: json['code'] as String,
name: json['name'] as String,
nameCn: json['name_cn'] as String?,
nameEn: json['name_en'] as String?,
iconFilename: json['icon_filename'] as String?,
isCrypto: json['is_crypto'] as bool? ?? false,
);
}

Map<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'name': name,
'name_cn': nameCn,
'name_en': nameEn,
'icon_filename': iconFilename,
'is_crypto': isCrypto,
};
}

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Bank &&
runtimeType == other.runtimeType &&
id == other.id;

@override
int get hashCode => id.hashCode;

@override
String toString() => 'Bank($displayName, $code)';
}
Loading