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/config/config.yaml b/config/config.yaml index 4d6f883b..6ad6d95c 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -13,9 +13,14 @@ accounts: id: "719540964775" # 需要抢购的门票数量 num: 1 - # 需要抢购的场次序号. - sessions: 1 - # 需要抢购的票档序号 + # 需要抢购的场次优先级列表(按顺序尝试,第1个为最高优先级) + # 当某个场次售罄或抢购失败时,会自动切换到下一个场次继续尝试 + # 场次序号从1开始,如 [1, 2, 3] 表示先尝试场次1,失败后尝试场次2,再失败尝试场次3 + sessions: + - 1 + - 2 + - 3 + # 需要抢购的票档序号(所有场次使用相同的票档序号) grade: 3 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..7e0af165 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,15 +1,94 @@ -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)] +#[serde(untagged)] +pub enum SessionsConfig { + Single(usize), + Multiple(Vec), +} + +impl SessionsConfig { + pub fn into_vec(self) -> Vec { + match self { + SessionsConfig::Single(s) => vec![s], + SessionsConfig::Multiple(v) => v, + } + } +} + +impl Default for SessionsConfig { + fn default() -> Self { + SessionsConfig::Multiple(Vec::new()) + } +} #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Ticket { pub id: String, pub num: usize, - pub sessions: usize, + #[serde(default)] + pub sessions: SessionsConfig, 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)); + } + let sessions = match &self.sessions { + SessionsConfig::Single(s) => vec![*s], + SessionsConfig::Multiple(v) => v.clone(), + }; + if sessions.is_empty() { + return Err(ConfigError::InvalidSessionIndex(0)); + } + for &session in &sessions { + if session == 0 { + return Err(ConfigError::InvalidSessionIndex(session)); + } + } + if self.grade == 0 { + return Err(ConfigError::InvalidGradeIndex(self.grade)); + } + Ok(()) + } + + pub fn get_sessions(&self) -> Vec { + match &self.sessions { + SessionsConfig::Single(s) => vec![*s], + SessionsConfig::Multiple(v) => v.clone(), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Account { pub cookie: String, @@ -19,33 +98,62 @@ 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, { - // 1.通过std::fs读取配置文件内容 - // 2.通过serde_yaml解析读取到的yaml配置转换成json对象 match serde_yaml::from_str::( &std::fs::read_to_string(path).unwrap_or_else(|_| panic!("failure read file {}", path)), ) { Ok(root_schema) => { - // 通过serde_json把json对象转换指定的model let data = serde_json::to_string_pretty(&root_schema).expect("failure to parse RootSchema"); let config = serde_json::from_str::(&data) .unwrap_or_else(|_| panic!("failure to format json str {}", &data)); - // 返回格式化结果 Some(config) } Err(err) => { - // 记录日志 error!("{}", err); - // 返回None None } } diff --git a/src/dm.rs b/src/dm.rs index eac34ac6..77581656 100644 --- a/src/dm.rs +++ b/src/dm.rs @@ -13,14 +13,89 @@ 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::调用成功"; +const SOLD_OUT_PATTERNS: &[&str] = &[ + "库存不足", + "暂无库存", + "票已售罄", + "无票", + "缺货", + "暂时缺货", + "库存为空", + "无法购买", + "该场次暂无", + "本档期无票", + "不满足购买条件", +]; + +#[derive(Debug, Clone, PartialEq)] +pub enum BuyAttemptResult { + Success, + SoldOut, + TemporaryError, +} + +fn is_sold_out_error(ret: &[String]) -> bool { + for msg in ret { + for pattern in SOLD_OUT_PATTERNS { + if msg.contains(pattern) { + return true; + } + } + } + false +} + +fn classify_error(ret: &[String]) -> BuyAttemptResult { + if is_sold_out_error(ret) { + BuyAttemptResult::SoldOut + } else { + BuyAttemptResult::TemporaryError + } +} + +#[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), + + #[error("生成订单失败: {0:?}")] + BuildOrderFailed(Vec), + + #[error("提交订单失败: {0:?}")] + SubmitOrderFailed(Vec), +} + +#[derive(Debug, Clone)] +pub struct BuildOrderError { + pub ret: Vec, +} + +impl std::fmt::Display for BuildOrderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.ret) + } +} + +impl std::error::Error for BuildOrderError {} + pub struct DmTicket { pub client: DmClient, pub account: Account, @@ -67,8 +142,11 @@ impl DmTicket { } } - // 生成订单 - pub async fn build_order(&self, item_id: &String, sku_id: &String) -> Result { + pub async fn build_order_with_ret( + &self, + item_id: &String, + sku_id: &String, + ) -> Result<(OrderInfo, Vec)> { let start = Instant::now(); let url = "https://mtop.damai.cn/h5/mtop.trade.order.build.h5/4.0/?"; @@ -84,12 +162,19 @@ impl DmTicket { match res.ret.contains(&SUCCESS_FLAG.to_string()) { true => { let order_info: OrderInfo = serde_json::from_value(res.data)?; - Ok(order_info) + Ok((order_info, res.ret)) } - false => Err(anyhow!("{:?}", res.ret)), + false => Err(BuildOrderError { ret: res.ret }.into()), } } + // 生成订单(保留原有API兼容性) + pub async fn build_order(&self, item_id: &String, sku_id: &String) -> Result { + self.build_order_with_ret(item_id, sku_id) + .await + .map(|(order_info, _)| order_info) + } + // 提交订单 pub async fn submit_order(&self, order_info: OrderInfo) -> Result { let start = Instant::now(); @@ -233,6 +318,82 @@ impl DmTicket { } } + async fn try_buy_single_attempt( + &self, + item_id: &String, + sku_id: &String, + ) -> BuyAttemptResult { + let start = Instant::now(); + + let order_info = match self.build_order_with_ret(item_id, sku_id).await { + Ok((data, _)) => { + debug!("成功生成订单..."); + data + } + Err(e) => { + if let Some(build_err) = e.downcast_ref::() { + return classify_error(&build_err.ret); + } + return BuyAttemptResult::TemporaryError; + } + }; + + let res = match self.submit_order(order_info).await { + Ok(r) => r, + Err(_) => return BuyAttemptResult::TemporaryError, + }; + + match res.ret.contains(&SUCCESS_FLAG.to_string()) { + true => { + info!( + "提交订单成功, 请尽快前往手机APP付款, 此次抢购花费时间:{:?}", + start.elapsed() + ); + BuyAttemptResult::Success + } + false => { + debug!( + "提交订单失败, 原因:{}, 此次抢购花费时间:{:?}", + res.ret[0], + start.elapsed() + ); + classify_error(&res.ret) + } + } + } + + pub async fn try_buy_session( + &self, + item_id: &String, + sku_id: &String, + sku_name: &str, + max_attempts: usize, + ) -> BuyAttemptResult { + for attempt in 0..max_attempts { + info!("[{}] 第{}次尝试抢购...", self.account.remark, attempt + 1); + + let result = self.try_buy_single_attempt(item_id, sku_id).await; + + match result { + BuyAttemptResult::Success => { + return BuyAttemptResult::Success; + } + BuyAttemptResult::SoldOut => { + info!("[{}] 检测到票档 {} 已售罄", self.account.remark, sku_name); + return BuyAttemptResult::SoldOut; + } + BuyAttemptResult::TemporaryError => { + if attempt < max_attempts - 1 { + debug!("[{}] 第{}次尝试失败,准备重试...", self.account.remark, attempt + 1); + } + } + } + } + + info!("[{}] 本场次内 {} 次尝试均失败", self.account.remark, max_attempts); + BuyAttemptResult::TemporaryError + } + pub fn ms_to_hms(&self, ms: i64) -> (u64, u64, f64) { let sec = ms as f64 / 1000.0; let hour = (sec / 3600.0) as u64; @@ -244,42 +405,34 @@ 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 grade_idx = self.account.ticket.grade - 1; + let session_priorities = self.account.ticket.get_sessions(); + + if session_priorities.is_empty() { + return Err(anyhow!("[{}] 场次优先级列表为空,请检查配置", self.account.remark)); + } - 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; - - let perform_id = ticket_info - .detail_view_component_map - .item - .item - .perform_bases[perfomr_idx] - .performs[0] - .perform_id + .item_name .clone(); - let perform_name = ticket_info + let perform_bases = &ticket_info .detail_view_component_map .item .item - .perform_bases[perfomr_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(); + .perform_bases; + + let total_sessions = perform_bases.len(); + info!("[{}] 该项目共有 {} 个场次", self.account.remark, total_sessions); + info!("[{}] 配置的场次优先级: {:?}", self.account.remark, session_priorities); let start_time_str = ticket_info .detail_view_component_map @@ -294,8 +447,8 @@ impl DmTicket { .parse::()?; println!( - "\r\n\t账号备注:{}\n\t门票名称:{}\n\t场次名称:{}\n\t票档名称:{}\n\t开抢时间:{}\n", - self.account.remark, ticket_name, perform_name, sku_name, start_time_str + "\r\n\t账号备注:{}\n\t门票名称:{}\n\t开抢时间:{}\n\t场次优先级: {:?}\n", + self.account.remark, ticket_name, start_time_str, session_priorities ); let (s, r) = async_channel::unbounded::(); @@ -321,18 +474,90 @@ impl DmTicket { print!("\r\t开抢倒计时:{}小时:{}分钟:{:.3}秒\t", hours, minutes, seconds); let _ =io::stdout().flush(); } - - // info!("剩余时间毫秒:{}", time_left_millis); } _ = r.recv() => { - for _ in 0..2 { - if let Ok(res) = self.buy(&item_id, &sku_id).await { - if res {// 抢购成功, 退出 + info!("[{}] 开始按优先级依次尝试各场次...", self.account.remark); + + for (priority_idx, &session_config) in session_priorities.iter().enumerate() { + let session_idx = session_config - 1; + + if session_idx >= total_sessions { + warn!("[{}] 场次序号 {} 超出范围(共{}个场次),跳过该场次", + self.account.remark, session_config, total_sessions); + continue; + } + + let perform_id = perform_bases[session_idx] + .performs[0] + .perform_id + .clone(); + + let perform_name = perform_bases[session_idx] + .performs[0] + .perform_name + .clone(); + + if priority_idx == 0 { + info!("[{}] ========================================", self.account.remark); + info!("[{}] 正在尝试第1优先级场次: {} (场次序号: {})", + self.account.remark, perform_name, session_config); + info!("[{}] ========================================", self.account.remark); + } else { + info!("[{}] ========================================", self.account.remark); + info!("[{}] 切换到第{}优先级场次: {} (场次序号: {})", + self.account.remark, priority_idx + 1, perform_name, session_config); + info!("[{}] ========================================", self.account.remark); + } + + info!("[{}] 正在获取场次/票档信息...", self.account.remark); + let perform_info = match self.get_perform_info(ticket_id.clone(), perform_id.clone()).await { + Ok(info) => info, + Err(e) => { + warn!("[{}] 获取场次信息失败: {}, 尝试下一个场次", self.account.remark, e); + continue; + } + }; + + 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 { + warn!("[{}] 票档序号 {} 超出范围(该场次共{}个票档),尝试下一个场次", + self.account.remark, self.account.ticket.grade, total_grades); + continue; + } + + 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(); + + info!("[{}] 当前票档: {} (票档序号: {})", + self.account.remark, sku_name, self.account.ticket.grade); + + let buy_result = self.try_buy_session(&item_id, &sku_id, &sku_name, 2).await; + + match buy_result { + BuyAttemptResult::Success => { + info!("[{}] 抢购成功!场次: {}, 票档: {}", + self.account.remark, perform_name, sku_name); return Ok(()); } + BuyAttemptResult::SoldOut => { + warn!("[{}] 场次 {} 票档 {} 已售罄,准备切换到下一个场次...", + self.account.remark, perform_name, sku_name); + continue; + } + BuyAttemptResult::TemporaryError => { + warn!("[{}] 场次 {} 抢购遇到临时错误(非售罄),继续尝试下一个场次...", + self.account.remark, perform_name); + continue; + } } } + + error!("[{}] 所有配置的场次都已尝试完毕,均未成功", self.account.remark); return Ok(()); } } 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(()) }