-
Notifications
You must be signed in to change notification settings - Fork 0
feat(banks): minimal Bank Selector — API + Flutter component #68
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
41d6927
d0851ff
31a9389
2c10359
6366db9
1438ac4
ef68226
2bbcfe2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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 '是否启用'; | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The logic for building the search query involves repeating 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation manually maps each You can use
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"), | ||
| })) | ||
| } | ||
| 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; | ||
|
|
||
| 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) | ||
| } | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| 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)'; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
icon_urlcolumn is defined in thebankstable schema but appears to be unused. The API handler injive-api/src/handlers/banks.rsdoes not select this column, and the Flutter client injive-flutter/lib/models/bank.dartdynamically constructs the icon URL fromicon_filename. To avoid confusion and redundant data storage, this column should be removed.