From 7d954e98ac16c41bac5308a9903a9e165b250b94 Mon Sep 17 00:00:00 2001 From: CGW406 <13565294+cgw406@user.noreply.gitee.com> Date: Fri, 24 Apr 2026 16:42:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=92=8C=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在config模块中添加配置验证逻辑和自定义错误类型 - 实现账户和票务信息的详细验证 - 在main.rs中添加配置验证流程 - 增强dm模块的错误处理,添加场次和票档范围检查 - 在client模块中实现API请求重试机制和超时处理 - 添加thiserror和backoff依赖以支持更好的错误处理 --- Cargo.toml | 4 +- src/client.rs | 126 +++++++++++++++++++++++++++++++++++++++++++------- src/config.rs | 77 +++++++++++++++++++++++++++++- src/dm.rs | 75 ++++++++++++++++++++++-------- src/main.rs | 75 +++++++++++++++++++++++++++--- 5 files changed, 314 insertions(+), 43 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0d1238b9..3ed2b74a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,4 +19,6 @@ md5 = {version="0.7.0"} dotenv = {version= "0.15.0"} futures = {version="0.3.28"} chrono = {version="0.4.24", features = ["unstable-locales"] } -async-channel={version = "1.8"} \ No newline at end of file +async-channel={version = "1.8"} +backoff = { version = "0.4.0", default-features = false, features = ["tokio"] } +thiserror = "1.0.50" \ No newline at end of file diff --git a/src/client.rs b/src/client.rs index fad2fa49..e110308d 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,15 +1,36 @@ -use std::{env, time::Instant}; +use std::{env, time::Duration, time::Instant}; use anyhow::Result; -use log::{debug, warn}; +use backoff::future::retry; +use backoff::ExponentialBackoff; +use log::{debug, error, warn}; use reqwest::{ header::{HeaderMap, HeaderValue}, Client, }; use serde_json::{json, Value}; +use thiserror::Error; use crate::models::{ticket::TicketInfoParams, DmRes, DmToken}; +const API_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_RETRIES: usize = 3; + +#[derive(Error, Debug)] +pub enum ApiError { + #[error("请求错误: {0}")] + RequestError(#[from] reqwest::Error), + + #[error("API返回错误: {0:?}")] + ApiError(Vec), + + #[error("解析错误: {0}")] + ParseError(#[from] serde_json::Error), + + #[error("操作超时")] + Timeout, +} + const SUCCESS_CODE: u64 = 200; const SYSTEM_ERROR_CODE: u16 = 500; @@ -19,27 +40,53 @@ pub struct TokenClient { impl TokenClient { pub fn new() -> Result { - let client = reqwest::Client::builder().build()?; + let client = reqwest::Client::builder() + .timeout(API_TIMEOUT) + .connect_timeout(API_TIMEOUT) + .build()?; Ok(Self { client }) } - // Get value from api. - pub async fn get_value(&self, key: &str) -> Result { + async fn get_value_with_retry(&self, key: &str) -> Result { + let backoff = ExponentialBackoff { + max_elapsed_time: Some(Duration::from_secs(60)), + ..Default::default() + }; + + retry(backoff, || async { + match self.get_value_internal(key).await { + Ok(value) => Ok(value), + Err(e) => { + if e.is_timeout() || e.is_connect() { + warn!("Token请求错误,准备重试: {}", e); + Err(backoff::Error::transient(e)) + } else { + Err(backoff::Error::permanent(e)) + } + } + } + }) + .await + .map_err(|e| anyhow::anyhow!("获取token失败: {}", e)) + } + + async fn get_value_internal(&self, key: &str) -> Result { let url = env::var("TOKEN_SERVER_URL").unwrap(); let params = json!({ "key": key, }); - let data = self + let response = self .client - .get(url) + .get(&url) .query(¶ms) + .timeout(API_TIMEOUT) .send() - .await? - .json::() .await?; + let data: Value = response.json().await?; + let code = data .get("code") .unwrap_or(&SYSTEM_ERROR_CODE.into()) @@ -48,7 +95,7 @@ impl TokenClient { Ok(match code { SUCCESS_CODE => { - let value = data["data"]["value"].as_str().unwrap().to_string(); + let value = data["data"]["value"].as_str().unwrap_or("").to_string(); debug!("Get {}:{}", key, value); value } @@ -59,7 +106,10 @@ impl TokenClient { }) } - // Get bx ua. + pub async fn get_value(&self, key: &str) -> Result { + self.get_value_with_retry(key).await + } + pub async fn get_bx_ua(&self) -> Result { let start = Instant::now(); let bx_ua = self.get_value("bx_ua").await?; @@ -67,7 +117,6 @@ impl TokenClient { Ok(bx_ua) } - /// Get bx token. pub async fn get_bx_token(&self) -> Result { let start = Instant::now(); let bx_token = self.get_value("bx_token").await?; @@ -88,6 +137,25 @@ pub struct DmClient { } pub async fn get_token(cookie: &str) -> Result { + let backoff = ExponentialBackoff { + max_elapsed_time: Some(Duration::from_secs(60)), + ..Default::default() + }; + + retry(backoff, || async { + match get_token_internal(cookie).await { + Ok(token) => Ok(token), + Err(e) => { + error!("获取Token失败,准备重试: {}", e); + Err(backoff::Error::transient(e)) + } + } + }) + .await + .map_err(|e| anyhow::anyhow!("获取Token最终失败: {}", e)) +} + +async fn get_token_internal(cookie: &str) -> Result { let mut headers = HeaderMap::new(); let url = "https://mtop.damai.cn/"; @@ -98,6 +166,8 @@ pub async fn get_token(cookie: &str) -> Result { .default_headers(headers) .cookie_store(true) .http2_prior_knowledge() + .timeout(API_TIMEOUT) + .connect_timeout(API_TIMEOUT) .build()?; let mut token = DmToken { @@ -108,7 +178,7 @@ pub async fn get_token(cookie: &str) -> Result { let url = "https://mtop.damai.cn/h5/mtop.damai.wireless.search.broadcast.list/1.0/?"; let params = TicketInfoParams::build()?; - let response = client.get(url).form(¶ms).send().await?; + let response = client.get(url).form(¶ms).timeout(API_TIMEOUT).send().await?; for cookie in response.cookies() { if cookie.name() == "_m_h5_tk" { @@ -152,6 +222,8 @@ impl DmClient { .http2_prior_knowledge() .user_agent("Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3") .use_rustls_tls() + .timeout(API_TIMEOUT) + .connect_timeout(API_TIMEOUT) .build()?; Ok(Self { client, @@ -162,6 +234,27 @@ impl DmClient { } pub async fn request(&self, url: &str, mut params: Value, data: Value) -> Result { + let backoff = ExponentialBackoff { + max_elapsed_time: Some(Duration::from_secs(60)), + ..Default::default() + }; + + let url = url.to_string(); + + retry(backoff, || async { + match self.request_internal(&url, params.clone(), data.clone()).await { + Ok(res) => Ok(res), + Err(e) => { + error!("API请求失败,准备重试: {}", e); + Err(backoff::Error::transient(e)) + } + } + }) + .await + .map_err(|e| anyhow::anyhow!("API请求最终失败: {}", e)) + } + + async fn request_internal(&self, url: &str, mut params: Value, data: Value) -> Result { let s = format!( "{}&{}&{}&{}", self.token.token, @@ -179,8 +272,6 @@ impl DmClient { let form = json!({ "data": serde_json::to_string(&data)?, - // "bx-umidtoken": params["bx-umidtoken"], - // "bx-ua": params["bx-ua"] }); let response = self @@ -188,11 +279,12 @@ impl DmClient { .post(url) .query(¶ms) .form(&form) + .timeout(API_TIMEOUT) .send() .await?; - let data = response.json::().await?; + let response_data = response.json::().await?; - Ok(data) + Ok(response_data) } } diff --git a/src/config.rs b/src/config.rs index 174944a2..bbefebe3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,28 @@ -use log::error; +use log::{error, warn}; use schemars::schema::RootSchema; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("Cookie不能为空")] + CookieEmpty, + + #[error("门票ID不能为空")] + TicketIdEmpty, + + #[error("购票数量无效: {0},必须大于0且不超过10")] + InvalidTicketCount(usize), + + #[error("场次索引无效: {0},必须大于0")] + InvalidSessionIndex(usize), + + #[error("票档索引无效: {0},必须大于0")] + InvalidGradeIndex(usize), + + #[error("备注不能为空")] + RemarkEmpty, +} #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Ticket { @@ -10,6 +32,24 @@ pub struct Ticket { pub grade: usize, } +impl Ticket { + pub fn validate(&self) -> Result<(), ConfigError> { + if self.id.is_empty() { + return Err(ConfigError::TicketIdEmpty); + } + if self.num == 0 || self.num > 10 { + return Err(ConfigError::InvalidTicketCount(self.num)); + } + if self.sessions == 0 { + return Err(ConfigError::InvalidSessionIndex(self.sessions)); + } + if self.grade == 0 { + return Err(ConfigError::InvalidGradeIndex(self.grade)); + } + Ok(()) + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Account { pub cookie: String, @@ -19,11 +59,46 @@ pub struct Account { pub earliest_submit_time: Option, } +impl Account { + pub fn validate(&self) -> Result<(), ConfigError> { + if self.cookie.is_empty() { + return Err(ConfigError::CookieEmpty); + } + if self.remark.is_empty() { + warn!("账户备注为空,建议添加备注以便区分不同账户"); + } + self.ticket.validate() + } +} + #[derive(Serialize, Deserialize, Debug)] pub struct Config { pub accounts: Vec, } +impl Config { + pub fn validate(&self) -> Result, Vec<(usize, String)>> { + let mut errors = Vec::new(); + + if self.accounts.is_empty() { + errors.push((0, "没有配置任何账户".to_string())); + return Err(errors); + } + + for (index, account) in self.accounts.iter().enumerate() { + if let Err(e) = account.validate() { + errors.push((index + 1, format!("账户{}配置错误: {}", index + 1, e))); + } + } + + if errors.is_empty() { + Ok(Vec::new()) + } else { + Err(errors) + } + } +} + fn load_config(path: &str) -> Option where T: DeserializeOwned, diff --git a/src/dm.rs b/src/dm.rs index eac34ac6..4e92ea36 100644 --- a/src/dm.rs +++ b/src/dm.rs @@ -13,14 +13,30 @@ use crate::{ DmRes, }, }; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Local}; use log::{debug, error, info, warn}; use serde_json::json; +use thiserror::Error; use tokio::signal; const SUCCESS_FLAG: &str = "SUCCESS::调用成功"; +#[derive(Error, Debug)] +pub enum RuntimeError { + #[error("场次索引 {0} 超出范围,该项目只有 {1} 个场次")] + SessionIndexOutOfRange(usize, usize), + + #[error("票档索引 {0} 超出范围,该场次只有 {1} 个票档")] + GradeIndexOutOfRange(usize, usize), + + #[error("API调用失败: {0}")] + ApiCallFailed(String), + + #[error("初始化失败: {0}")] + InitializationFailed(String), +} + pub struct DmTicket { pub client: DmClient, pub account: Account, @@ -244,42 +260,65 @@ impl DmTicket { pub async fn run(&self) -> Result<()> { let ticket_id = self.account.ticket.id.clone(); - let perfomr_idx = self.account.ticket.sessions - 1; // 场次索引 - let sku_idx = self.account.ticket.grade - 1; // 票档索引 + let session_idx = self.account.ticket.sessions - 1; // 场次索引(从1开始转成从0开始) + let grade_idx = self.account.ticket.grade - 1; // 票档索引(从1开始转成从0开始) - info!("正在获取演唱会信息..."); - let ticket_info = self.get_ticket_info(ticket_id.clone()).await?; + info!("[{}] 正在获取演唱会信息...", self.account.remark); + let ticket_info = self.get_ticket_info(ticket_id.clone()).await + .with_context(|| format!("[{}] 获取演唱会信息失败", self.account.remark))?; let ticket_name = ticket_info .detail_view_component_map .item .static_data .item_base - .item_name; + .item_name + .clone(); - let perform_id = ticket_info + let perform_bases = &ticket_info .detail_view_component_map .item .item - .perform_bases[perfomr_idx] + .perform_bases; + + let total_sessions = perform_bases.len(); + info!("[{}] 该项目共有 {} 个场次", self.account.remark, total_sessions); + + if session_idx >= total_sessions { + return Err(anyhow!(RuntimeError::SessionIndexOutOfRange( + self.account.ticket.sessions, + total_sessions + ))); + } + + let perform_id = perform_bases[session_idx] .performs[0] .perform_id .clone(); - let perform_name = ticket_info - .detail_view_component_map - .item - .item - .perform_bases[perfomr_idx] + let perform_name = perform_bases[session_idx] .performs[0] .perform_name .clone(); - info!("正在获取场次/票档信息..."); - let perform_info = self.get_perform_info(ticket_id, perform_id).await?; - let sku_id = perform_info.perform.sku_list[sku_idx].sku_id.clone(); - let sku_name = perform_info.perform.sku_list[sku_idx].price_name.clone(); - let item_id = perform_info.perform.sku_list[sku_idx].item_id.clone(); + info!("[{}] 正在获取场次/票档信息...", self.account.remark); + let perform_info = self.get_perform_info(ticket_id.clone(), perform_id.clone()).await + .with_context(|| format!("[{}] 获取场次/票档信息失败", self.account.remark))?; + + let sku_list = &perform_info.perform.sku_list; + let total_grades = sku_list.len(); + info!("[{}] 该场次共有 {} 个票档", self.account.remark, total_grades); + + if grade_idx >= total_grades { + return Err(anyhow!(RuntimeError::GradeIndexOutOfRange( + self.account.ticket.grade, + total_grades + ))); + } + + let sku_id = sku_list[grade_idx].sku_id.clone(); + let sku_name = sku_list[grade_idx].price_name.clone(); + let item_id = sku_list[grade_idx].item_id.clone(); let start_time_str = ticket_info .detail_view_component_map diff --git a/src/main.rs b/src/main.rs index 1e227c1a..47b121d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ use anyhow::Result; use dm_ticket::{ - config::{load_global_config, Config}, + config::{load_global_config, Account, Config}, dm, }; use futures::future::join_all; use dotenv::dotenv; +use log::{error, info, warn}; use std::env; #[tokio::main] @@ -22,19 +23,81 @@ async fn main() -> Result<()> { pretty_env_logger::init(); - let config: Config = load_global_config().unwrap(); + info!("开始加载配置..."); + let config: Config = load_global_config().expect("加载配置文件失败"); + + info!("开始验证配置..."); + match config.validate() { + Ok(_) => { + info!("配置验证通过"); + } + Err(errors) => { + error!("配置验证失败,发现以下错误:"); + for (index, err) in errors { + error!(" [账户{}] {}", index, err); + } + error!("请修正配置后再启动程序"); + std::process::exit(1); + } + } + + let valid_accounts: Vec = config.accounts + .into_iter() + .enumerate() + .filter_map(|(index, account)| { + match account.validate() { + Ok(_) => { + info!("账户 [{}] 配置验证通过", account.remark); + Some(account) + } + Err(e) => { + error!("账户 [{}] 配置错误: {}, 跳过该账户", + if account.remark.is_empty() { format!("第{}个", index + 1) } else { account.remark.clone() }, + e + ); + None + } + } + }) + .collect(); + + if valid_accounts.is_empty() { + error!("没有有效的账户配置,程序退出"); + std::process::exit(1); + } + + info!("共有 {} 个有效账户,开始启动...", valid_accounts.len()); let mut handlers = Vec::new(); - for account in config.accounts.iter() { - let account = account.clone(); + for account in valid_accounts.into_iter() { + let remark = account.remark.clone(); let handler = tokio::spawn(async move { - let dm_ticket = dm::DmTicket::new(account).await.unwrap(); - dm_ticket.run().await.unwrap(); + info!("[{}] 正在初始化...", remark); + + match dm::DmTicket::new(account).await { + Ok(dm_ticket) => { + info!("[{}] 初始化成功", remark); + + if let Err(e) = dm_ticket.run().await { + error!("[{}] 运行时错误: {}", remark, e); + } + } + Err(e) => { + error!("[{}] 初始化失败: {}", remark, e); + } + } + + warn!("[{}] 账户任务结束", remark); }); handlers.push(handler); } + + info!("所有账户已启动,等待执行完成..."); + join_all(handlers).await; + info!("所有账户任务已完成,程序退出"); + Ok(()) }