diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4db4a58..9175079 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,3 +27,5 @@ rusqlite = { version = "0.37.0", features = ["bundled"] } tokio = "1.48.0" once_cell = "1.21.3" chrono = "0.4" +reqwest = "0.12.25" +rust_decimal = "1.39.0" diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index 69373fb..300d3f3 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -26,4 +26,12 @@ pub const CREATE_HISTORY_TABLE: &str = " ); "; -pub const TABLES: [&str; 2] = [CREATE_WALLET_TABLE, CREATE_HISTORY_TABLE]; +pub const CREATE_TOKEN_PRICE_TABLE: &str = " + CREATE TABLE IF NOT EXISTS token_price ( + symbol TEXT PRIMARY KEY, -- 设置为主键 + usd INTEGER NOT NULL, + expo INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) +"; +pub const TABLES: [&str; 3] = [CREATE_WALLET_TABLE, CREATE_HISTORY_TABLE, CREATE_TOKEN_PRICE_TABLE]; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 54d07f0..05f3d99 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -25,6 +25,7 @@ async fn main() { service::wallet::transfer, service::wallet::account_history, service::wallet::transfer_detail, + service::wallet::get_token_price, ]) .setup(|app| { // 初始化数据库 diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 5379455..4942862 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -2,3 +2,4 @@ pub mod dto; pub mod history; pub mod network; pub mod wallet; +pub mod token_price; diff --git a/src-tauri/src/models/token_price.rs b/src-tauri/src/models/token_price.rs new file mode 100644 index 0000000..5323ad4 --- /dev/null +++ b/src-tauri/src/models/token_price.rs @@ -0,0 +1,9 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TokenPrice { + pub symbol: String, + pub usd: i64, // 价格 * 1e6 + pub expo: i64, //小数位数 + pub updated_at: i64, // 时间戳 +} \ No newline at end of file diff --git a/src-tauri/src/repository/mod.rs b/src-tauri/src/repository/mod.rs index d3356a1..175727b 100644 --- a/src-tauri/src/repository/mod.rs +++ b/src-tauri/src/repository/mod.rs @@ -1,2 +1,3 @@ pub mod history_repo; pub mod wallet_repo; +pub mod token_price_repo; diff --git a/src-tauri/src/repository/token_price_repo.rs b/src-tauri/src/repository/token_price_repo.rs new file mode 100644 index 0000000..37c5970 --- /dev/null +++ b/src-tauri/src/repository/token_price_repo.rs @@ -0,0 +1,119 @@ + +use std::{ + result::Result, sync::{Arc, Mutex} +}; + +use crate::{ + db::connection::DB_CONN, + models::token_price::TokenPrice +}; +use rusqlite::Connection; + + +pub struct TokenPriceRepository { + conn: Arc>, +} + +impl TokenPriceRepository { + pub fn new() -> Self { + let conn = DB_CONN.get().expect("数据库未初始化").clone(); // 拿到 Arc> + Self { conn } + } + + fn get_conn(&'_ self) -> Result, Box> { + match self.conn.lock() { + Ok(guard) => Ok(guard), + Err(e) => Err(format!("获取数据库连接失败: {}", e).into()) + } + } + + pub fn get_multi(&self, symbols: &[String]) -> Vec { + if symbols.is_empty() { + return vec![]; + } + + let conn = match self.get_conn() { + Ok(conn) => conn, + Err(e) => { + println!("获取数据库连接异常{}", e); + return vec![]; + } + }; + + // 动态构建占位符 (?, ?, ?) + let placeholders = symbols + .iter() + .map(|_| "?") + .collect::>() + .join(","); + + let sql = format!( + "SELECT symbol, usd, expo, updated_at + FROM token_price + WHERE symbol IN ({})", + placeholders + ); + + let mut stmt = match conn.prepare(&sql) { + Ok(stmt) => stmt, + Err(e) => { + println!("sql 异常{}", e); + return vec![]; + } + }; + + let rows = stmt + .query_map(rusqlite::params_from_iter(symbols.iter()), |row| { + Ok(TokenPrice { + symbol: row.get(0)?, + usd: row.get(1)?, + expo: row.get(2)?, + updated_at: row.get(3)?, + }) + }) + .unwrap(); + + rows.filter_map(|row| row.ok()).collect() +} + + + +pub fn save_all(&self, prices: &[TokenPrice]) -> Result<(), Box> { + // 获取连接 + let mut conn = self.conn.lock() + .map_err(|e| format!("获取数据库锁失败: {}", e))?; + + // 开始事务 + let tx = conn.transaction()?; + + { + // 关键:prepare 返回 Result,需要用 ? 解包 + let mut stmt = tx.prepare( + "INSERT INTO token_price (symbol, usd, expo, updated_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(symbol) DO UPDATE SET + usd = excluded.usd, + expo = excluded.expo, + updated_at = excluded.updated_at" + )?; // 注意这个 ? 号! + + // 现在 stmt 是 Statement 类型,可以调用 execute + for price in prices { + stmt.execute(rusqlite::params![ + &price.symbol, + price.usd, + price.expo, + price.updated_at + ])?; // 这个 execute 是 Statement 的方法 + } + } + + + tx.commit()?; + + println!("✅ 成功保存 {} 个价格", prices.len()); + + Ok(()) +} + +} diff --git a/src-tauri/src/service/notice.rs b/src-tauri/src/service/notice.rs index 1b20df4..4a0eef0 100644 --- a/src-tauri/src/service/notice.rs +++ b/src-tauri/src/service/notice.rs @@ -17,6 +17,7 @@ pub enum MsgType { RefreshWallet, /* 前端单个账户历史刷新 */ RefreshHistory, + RefreshTokenPrice, } impl MsgType { @@ -28,6 +29,7 @@ impl MsgType { MsgType::TransferInfo => "TRANSFER_INFO", MsgType::RefreshHistory => "REFRESH_HISTORY", MsgType::BalanceRefreshEnd => "BALANCE_REFRESH_END", + MsgType::RefreshTokenPrice => "REFRESH_TOKEN_PRICE", } } } diff --git a/src-tauri/src/service/rpc.rs b/src-tauri/src/service/rpc.rs index 9ad6a6d..eea7764 100644 --- a/src-tauri/src/service/rpc.rs +++ b/src-tauri/src/service/rpc.rs @@ -1,12 +1,17 @@ use std::thread; use std::time::Duration; - +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + use crate::try_notice; use crate::{ - models::{history::History, network::SolanaNetwork, wallet::Wallet}, + models::{history::History, network::SolanaNetwork, wallet::Wallet, token_price::*}, repository::history_repo::HistoryRepository, + repository::token_price_repo::TokenPriceRepository, service::notice::{self, show, NoticeType}, + utils::http_client::get_pyth_price, }; + use chrono::Local; use solana_client::rpc_client::{GetConfirmedSignaturesForAddress2Config, RpcClient}; use solana_sdk::transaction::Transaction; @@ -14,6 +19,8 @@ use solana_sdk::{ native_token::LAMPORTS_PER_SOL, pubkey::Pubkey, signature::Keypair, signer::Signer, }; +const CACHE_TTL: i64 = 300; // 5 分钟 + pub fn transfer(payer: Wallet, receiver_public_key: String, amount: f32) { // [校验] 如果收款账户无法解析则提示 let receiver_result: Result = receiver_public_key @@ -143,3 +150,87 @@ pub fn get_public_key_by_str(public_key_str: &str) -> Result { // .get_transaction(&sig, UiTransactionEncoding::Json) // .map_err(|e| e.to_string()) // } + +pub async fn get_price(symbol: &str) -> Result, String> { + let symbol_list: Vec = symbol + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + if symbol_list.is_empty() { + return Err("Symbol list is empty".to_string()); + } + + println!("[DEBUG] 查询符号: {:?}", symbol_list); + + // ① 先查缓存 + let repo: TokenPriceRepository = TokenPriceRepository::new(); + let mut need_remote_query_token: Vec = vec![]; + let now = now_sec(); + + let local_price_list = repo.get_multi(&symbol_list); + + // 如果本地有数据,检查是否需要更新 + if !local_price_list.is_empty() { + for local_price in &local_price_list { + if now - local_price.updated_at > CACHE_TTL { + need_remote_query_token.push(local_price.symbol.clone()); + } + } + + if need_remote_query_token.is_empty() { + println!("[INFO] 使用缓存数据,数量: {}", local_price_list.len()); + return Ok(local_price_list); + } + println!("[INFO] 需要更新 {} 个价格", need_remote_query_token.len()); + } else { + println!("[INFO] 缓存中没有数据,全部远程查询"); + need_remote_query_token = symbol_list.clone(); + } + + // ② 外网获取价格(使用更安全的调用) + println!("[INFO] 开始远程查询 Pyth 价格..."); + let token_price_opt = match get_pyth_price(&need_remote_query_token).await { + Ok(prices) => { + println!("[INFO] 远程查询成功,获取到 {} 个价格", prices.len()); + prices + } + Err(e) => { + // 如果远程查询失败,但有缓存数据,则返回缓存数据 + if !local_price_list.is_empty() { + eprintln!("[WARN] 远程查询失败,使用缓存数据: {}", e); + return Ok(local_price_list); + } + return Err(format!("Failed to fetch price: {}", e)); + } + }; + + // ③ 如果没有价格就直接返回缓存或空 + if token_price_opt.is_empty() { + eprintln!("[WARN] 远程查询返回空价格列表"); + if !local_price_list.is_empty() { + return Ok(local_price_list); + } + return Ok(vec![]); + } + + // ④ 保存到数据库 + println!("[INFO] 保存 {} 个价格到数据库", token_price_opt.len()); + println!("[INFO] 保存 {:?} 到数据库", token_price_opt); + match repo.save_all(&token_price_opt) { + Ok(_) => println!("数据库保存成功 data={:?}", token_price_opt), + Err(e) => println!("数据库保存远程价格失败:data={:?}, e= {}",token_price_opt, e) + }; + + // ⑤ 发送通知 + notice::msg(notice::MsgType::RefreshTokenPrice, &token_price_opt); + + Ok(token_price_opt) +} +fn now_sec() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} diff --git a/src-tauri/src/service/wallet.rs b/src-tauri/src/service/wallet.rs index e15b8e0..1a89939 100644 --- a/src-tauri/src/service/wallet.rs +++ b/src-tauri/src/service/wallet.rs @@ -1,6 +1,8 @@ use crate::models::history::HistoryQuery; use crate::models::history::PaginatedHistory; -use crate::models::{dto::TransferParams, network::SolanaNetwork, wallet::Wallet}; +use crate::models::{dto::TransferParams, network::SolanaNetwork, wallet::Wallet, + token_price::TokenPrice, +}; use crate::repository::history_repo::HistoryRepository; use crate::repository::wallet_repo::WalletRepository; use crate::service::notice::MsgType; @@ -100,3 +102,10 @@ pub async fn transfer_detail(_signature: &str) -> Result<(), String> { // rpc::transfer_detail(signature) Ok(()) } + +#[tauri::command] +pub async fn get_token_price(symbol:String) -> Result, String> { + let token_price_list = rpc::get_price(&symbol).await?; + Ok(token_price_list) +} + diff --git a/src-tauri/src/utils/http_client.rs b/src-tauri/src/utils/http_client.rs new file mode 100644 index 0000000..3ea8371 --- /dev/null +++ b/src-tauri/src/utils/http_client.rs @@ -0,0 +1,208 @@ +use reqwest::Client; +use serde_json::Value; +use crate::models::token_price::TokenPrice; +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +const PYTH_URL_PREFIX:&str = "https://hermes.pyth.network/v2/"; + +pub async fn get_pyth_price(symbols: &[String]) -> Result, Box> { + let client = Client::new(); + let mut price_feed_ids: Vec = Vec::new(); + let mut id_to_symbol: HashMap = HashMap::new(); + + // ------------------------ + // ① 查询 symbol 对应的 feedID + // ------------------------ + for sym in symbols { + let query_symbol = format!("Crypto.{}/USD", sym.to_uppercase()); + println!("query_symbol = {}", query_symbol); + // 添加错误处理和日志 + let resp_text = match client + .get(format!("{}price_feeds", PYTH_URL_PREFIX)) + .query(&[("asset_type", "crypto")]) + .send() + .await + { + Ok(response) => { + match response.text().await { + Ok(text) => text, + Err(e) => { + eprintln!("[ERROR] 解析响应文本失败: {}", e); + continue; // 跳过这个 symbol,继续下一个 + } + } + } + Err(e) => { + eprintln!("[ERROR] 请求 feedID 失败 ({}): {}", sym, e); + continue; + } + }; + + println!("[DEBUG] 查询 {} 的 resp_text 长度: {}", sym, resp_text.len()); + // println!("resp_text = {}", resp_text); + + // 安全解析 JSON + let json_array: Vec = match serde_json::from_str(&resp_text) { + Ok(v) => v, + Err(e) => { + eprintln!("[ERROR] JSON 解析失败: {}", e); + eprintln!("[DEBUG] 原始响应: {}", &resp_text[..200.min(resp_text.len())]); + continue; + } + }; + + println!("json_array .length = {}", json_array.len()); + //遍历数组 + for item in json_array { + println!("item ={}", item); + println!("attributes = {:?}, id= {:?}", item.get("attributes"), item.get("id")); + // 使用模式匹配安全解包 + match (item.get("attributes"), item.get("id")) { + (Some(attr), Some(id_val)) => { + if let Some(symbol_val) = attr.get("symbol").and_then(|v| v.as_str()) { + println!("symbol_val = {}, query_symbol = {}", symbol_val, query_symbol); + if symbol_val == query_symbol { + if let Some(id_str) = id_val.as_str() { + price_feed_ids.push(id_str.to_string()); + id_to_symbol.insert(id_str.to_string(), sym.clone()); + println!("[INFO] 找到 {} 的 feedID: {}", sym, id_str); + } + } + } + } + _ => continue, + } + } + } + + // 如果没有要查的 feedID → 直接返回空列表 + if price_feed_ids.is_empty() { + eprintln!("[WARN] 没有找到任何 feedID"); + return Ok(vec![]); + } + + println!("[INFO] 准备查询价格,feedIDs: {:?}", price_feed_ids); + + // ------------------------ + // ② 拼接 HTTP query 参数 + // ------------------------ + let mut params: Vec<(String, String)> = price_feed_ids + .iter() + .map(|id| ("ids[]".to_string(), id.clone())) + .collect(); + params.push(("parsed".to_string(), "true".to_string())); + + // ------------------------ + // ③ 请求最新价格 + // ------------------------ + let resp_text = match client + .get( format!("{}updates/price/latest", PYTH_URL_PREFIX)) + .query(¶ms) + .send() + .await + { + Ok(response) => { + // 先检查状态码 + if !response.status().is_success() { + eprintln!("[ERROR] HTTP 状态码: {}", response.status()); + } + match response.text().await { + Ok(text) => { + println!("[DEBUG] 价格响应长度: {}", text.len()); + // 只打印前500字符避免刷屏 + // if text.len() > 500 { + // println!("[DEBUG] 价格响应(前500字符): {}", &text[..500]); + // } else { + println!("[DEBUG] 价格响应: {}", text); + // } + text + } + Err(e) => { + eprintln!("[ERROR] 读取价格响应失败: {}", e); + return Err(Box::new(e)); + } + } + } + Err(e) => { + eprintln!("[ERROR] 请求价格失败: {}", e); + return Err(Box::new(e)); + } + }; + + + // 安全解析 JSON + let json: Value = match serde_json::from_str(&resp_text) { + Ok(v) => v, + Err(e) => { + eprintln!("[ERROR] 价格 JSON 解析失败: {}", e); + eprintln!("[DEBUG] 错误位置附近的文本: {}", &resp_text); + return Err(Box::new(e)); + } + }; + + let now = match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => duration.as_secs() as i64, + Err(e) => { + eprintln!("[ERROR] 获取当前时间失败: {}", e); + return Err(Box::new(e)); + } + }; + + let mut token_price_list: Vec = Vec::new(); + + // 安全访问 parsed 数组 + let Some(feed_arr) = json.get("parsed").and_then(|v| v.as_array()) else { + eprintln!("[WARN] 响应中没有 parsed 数组, json= {}", json); + return Ok(vec![]); + }; + + println!("feed_arr = {:?}", feed_arr); + for feed in feed_arr { + // 使用模式匹配一次性安全解包所有字段 + match ( + feed.get("id").and_then(|v| v.as_str()), + feed.get("price"), +) { + (Some(id), Some(price_info)) => { + match ( + price_info.get("price").and_then(|v| v.as_str()), + price_info.get("expo").and_then(|v| v.as_i64()), + id_to_symbol.get(id), + ) { + (Some(price_str), Some(expo), Some(symbol)) => { + // 将字符串价格解析为数字 + match price_str.parse::() { + Ok(price) => { + token_price_list.push(TokenPrice { + symbol: symbol.clone(), + usd: price, + expo, + updated_at: now, + }); + + // 计算实际价格用于显示 + let actual_price = price as f64 * 10_f64.powi(expo as i32); + println!("[INFO] 获取到价格: {} = {} (原始: {}e{})", + symbol, actual_price, price, expo); + } + Err(e) => { + eprintln!("[WARN] 价格解析失败: id={}, price_str={}, error={}", + id, price_str, e); + continue; + } + } + } + _ => { + eprintln!("[WARN] 价格数据不完整: id={}", id); + continue; + } + } + } + _ => continue, +} + } + + println!("[INFO] 成功获取 {} 个价格", token_price_list.len()); + Ok(token_price_list) +} \ No newline at end of file diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index 10532f0..7fff1d7 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -1,2 +1,3 @@ #[macro_use] pub mod macros; +pub mod http_client; diff --git a/src/api/index.ts b/src/api/index.ts index a19e509..d67e891 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,5 +1,5 @@ import { requery } from "./requery"; -import { WalletHistoryResp, TransferParams, WalletInfo, HistoryQuery } from "@/models" +import { WalletHistoryResp, TransferParams, WalletInfo, HistoryQuery , TokePriceResp} from "@/models" import { InvokeArgs } from "@tauri-apps/api/core"; export default { @@ -12,4 +12,5 @@ export default { Transfer: (args?: TransferParams) => requery("transfer", { params: args }), WalletHistory: (params: HistoryQuery) => requery("account_history", { query: params }), TransferDetail: (signature: string) => requery("transfer_detail", { signature: signature }), + TokenPrice:(symbol?:InvokeArgs) => requery>("get_token_price", symbol), } diff --git a/src/components/TokenPriceCard.vue b/src/components/TokenPriceCard.vue new file mode 100644 index 0000000..bb437f3 --- /dev/null +++ b/src/components/TokenPriceCard.vue @@ -0,0 +1,137 @@ + + + + + + \ No newline at end of file diff --git a/src/models/index.ts b/src/models/index.ts index c2f9911..b79fd31 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -16,6 +16,11 @@ export interface WalletHistoryResp { total: number; } +export interface TokePriceResp { + usd: number; + expo: number; + symbol: string; +} export enum MsgType { /* 显示在界面正上方 */ ALERT = "ALERT", @@ -33,6 +38,8 @@ export enum MsgType { REFRESH_HISTORY = "REFRESH_HISTORY", /* 刷新单个钱包 */ REFRESH_WALLET = "REFRESH_WALLET", + /* 刷新价格 */ + REFRESH_TOKEN_PRICE = "REFRESH_TOKEN_PRICE", } export enum NetworkHealth { diff --git a/src/pages/wallet/Item.vue b/src/pages/wallet/Item.vue index 4e4d0bf..b0ac17d 100644 --- a/src/pages/wallet/Item.vue +++ b/src/pages/wallet/Item.vue @@ -97,7 +97,7 @@ import { ref, onMounted, onUnmounted } from "vue"; import API from "@/api"; import { useRoute } from "vue-router"; -import { AccountHistory, MsgType, HistoryQuery } from "@/models"; +import { AccountHistory, MsgType, HistoryQuery} from "@/models"; import { formatRelativeTime, lamportsToSol, formatCardNumber } from "@/utils/common"; import { listen } from "@tauri-apps/api/event"; import { notify } from "@/utils/notify"; @@ -146,6 +146,8 @@ async function copy(content: string) { } } + + let unlisten: (() => void) | null = null; onMounted(async () => { dataInit(); diff --git a/src/pages/wallet/List.vue b/src/pages/wallet/List.vue index 484381a..8665932 100644 --- a/src/pages/wallet/List.vue +++ b/src/pages/wallet/List.vue @@ -1,5 +1,13 @@