mirror of
https://github.com/perfect-panel/ppanel-web.git
synced 2026-08-29 05:52:08 -04:00
Initial
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "email"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
lettre = { version = "0.11", default-features = false, features = ["tokio1", "builder", "smtp-transport", "tokio1-native-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
thiserror = "2"
|
||||
async-trait = "0.1"
|
||||
anyhow = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod manager;
|
||||
pub mod platform;
|
||||
pub mod sender;
|
||||
pub mod smtp;
|
||||
pub mod template;
|
||||
pub mod worker;
|
||||
|
||||
pub use manager::{get_global_manager, set_global_manager, WorkerManager};
|
||||
pub use platform::{get_supported_platforms, Platform, PlatformInfo};
|
||||
pub use sender::{new_sender, EmailError, Sender};
|
||||
pub use worker::{ErrorInfo, Worker, WorkerStatus};
|
||||
@@ -0,0 +1,121 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
use crate::sender::Sender;
|
||||
use crate::worker::{TaskInfo, Worker};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait TaskRepo: Send + Sync {
|
||||
async fn find_one(&self, id: i64) -> Result<TaskInfo, anyhow::Error>;
|
||||
async fn update(&self, data: &TaskInfo) -> Result<(), anyhow::Error>;
|
||||
async fn update_status(&self, id: i64, status: i16) -> Result<(), anyhow::Error>;
|
||||
fn is_cancelled(&self, id: i64) -> bool;
|
||||
}
|
||||
|
||||
pub struct WorkerManager {
|
||||
repo: Arc<dyn TaskRepo>,
|
||||
sender: Arc<dyn Sender>,
|
||||
workers: RwLock<HashMap<i64, WorkerHandle>>,
|
||||
}
|
||||
|
||||
struct WorkerHandle {
|
||||
worker: Arc<Worker>,
|
||||
}
|
||||
|
||||
impl WorkerManager {
|
||||
pub fn new(repo: Arc<dyn TaskRepo>, sender: Arc<dyn Sender>) -> Arc<Self> {
|
||||
let manager = Arc::new(WorkerManager {
|
||||
repo,
|
||||
sender,
|
||||
workers: RwLock::new(HashMap::new()),
|
||||
});
|
||||
|
||||
let mgr = manager.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
sleep(Duration::from_secs(60)).await;
|
||||
mgr.check_workers().await;
|
||||
}
|
||||
});
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
pub async fn add_worker(&self, id: i64) {
|
||||
let mut workers = self.workers.write().await;
|
||||
if workers.contains_key(&id) {
|
||||
tracing::info!(
|
||||
"Batch Send Email: Worker already exists, task_id={}",
|
||||
id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let worker = Arc::new(Worker::new(id, self.repo.clone(), self.sender.clone()));
|
||||
let handle = WorkerHandle {
|
||||
worker: worker.clone(),
|
||||
};
|
||||
workers.insert(id, handle);
|
||||
|
||||
tracing::info!(
|
||||
"Batch Send Email: Added new worker, task_id={}",
|
||||
id
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
worker.start().await;
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn get_worker(&self, id: i64) -> Option<Arc<Worker>> {
|
||||
let workers = self.workers.read().await;
|
||||
workers.get(&id).map(|h| h.worker.clone())
|
||||
}
|
||||
|
||||
pub async fn remove_worker(&self, id: i64) {
|
||||
let mut workers = self.workers.write().await;
|
||||
if workers.remove(&id).is_some() {
|
||||
tracing::info!(
|
||||
"Batch Send Email: Removed worker, task_id={}",
|
||||
id
|
||||
);
|
||||
} else {
|
||||
tracing::error!(
|
||||
"Batch Send Email: Worker not found for removal, task_id={}",
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_workers(&self) {
|
||||
let mut workers = self.workers.write().await;
|
||||
let mut to_remove = Vec::new();
|
||||
|
||||
for (&id, handle) in workers.iter() {
|
||||
if handle.worker.is_running().await as i16 == 2 {
|
||||
to_remove.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
for id in to_remove {
|
||||
workers.remove(&id);
|
||||
tracing::info!(
|
||||
"Batch Send Email: Removed completed worker, task_id={}",
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static MANAGER: std::sync::OnceLock<Arc<WorkerManager>> = std::sync::OnceLock::new();
|
||||
|
||||
pub fn set_global_manager(manager: Arc<WorkerManager>) -> Result<(), Arc<WorkerManager>> {
|
||||
MANAGER.set(manager)
|
||||
}
|
||||
|
||||
pub fn get_global_manager() -> Option<&'static Arc<WorkerManager>> {
|
||||
MANAGER.get()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
Smtp,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl FromStr for Platform {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"smtp" => Ok(Platform::Smtp),
|
||||
_ => Ok(Platform::Unsupported),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Platform::Smtp => "smtp",
|
||||
Platform::Unsupported => "unsupported",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PlatformInfo {
|
||||
pub platform: String,
|
||||
pub platform_url: String,
|
||||
pub platform_field_description: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn get_supported_platforms() -> Vec<PlatformInfo> {
|
||||
let mut desc = HashMap::new();
|
||||
desc.insert("host".into(), "host".into());
|
||||
desc.insert("port".into(), "port".into());
|
||||
desc.insert("user".into(), "user".into());
|
||||
desc.insert("pass".into(), "pass".into());
|
||||
desc.insert("from".into(), "from".into());
|
||||
desc.insert("reply_to".into(), "reply_to".into());
|
||||
desc.insert("ssl".into(), "ssl".into());
|
||||
|
||||
vec![PlatformInfo {
|
||||
platform: "smtp".into(),
|
||||
platform_url: String::new(),
|
||||
platform_field_description: desc,
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::platform::Platform;
|
||||
use crate::smtp::{SmtpClient, SmtpConfig};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EmailError {
|
||||
#[error("SMTP transport error: {0}")]
|
||||
SmtpTransport(#[from] lettre::transport::smtp::Error),
|
||||
#[error("Message build error: {0}")]
|
||||
MessageBuild(String),
|
||||
#[error("Unsupported platform: {0}")]
|
||||
UnsupportedPlatform(String),
|
||||
#[error("Config parse error: {0}")]
|
||||
ConfigParse(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait Sender: Send + Sync {
|
||||
async fn send(&self, to: &[String], subject: &str, body: &str) -> Result<(), EmailError>;
|
||||
}
|
||||
|
||||
pub fn new_sender(
|
||||
platform: &str,
|
||||
config: &str,
|
||||
site_name: &str,
|
||||
) -> Result<Box<dyn Sender>, EmailError> {
|
||||
match Platform::from_str(platform).unwrap_or(Platform::Unsupported) {
|
||||
Platform::Smtp => {
|
||||
let mut cfg: SmtpConfig = serde_json::from_str(config)?;
|
||||
cfg.site_name = site_name.to_string();
|
||||
Ok(Box::new(SmtpClient::new(cfg)))
|
||||
}
|
||||
_ => Err(EmailError::UnsupportedPlatform(platform.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::sender::EmailError;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SmtpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub pass: String,
|
||||
pub from: String,
|
||||
pub reply_to: Option<String>,
|
||||
pub ssl: bool,
|
||||
#[serde(default)]
|
||||
pub site_name: String,
|
||||
}
|
||||
|
||||
pub struct SmtpClient {
|
||||
config: SmtpConfig,
|
||||
mailer: AsyncSmtpTransport<Tokio1Executor>,
|
||||
}
|
||||
|
||||
impl SmtpClient {
|
||||
pub fn new(config: SmtpConfig) -> Self {
|
||||
let creds = Credentials::new(config.user.clone(), config.pass.clone());
|
||||
|
||||
let tls_params = TlsParameters::new(config.host.clone())
|
||||
.expect("failed to build TLS parameters");
|
||||
|
||||
let tls = if config.ssl {
|
||||
Tls::Wrapper(tls_params)
|
||||
} else {
|
||||
Tls::Required(tls_params)
|
||||
};
|
||||
|
||||
let mailer = AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
|
||||
.expect("failed to build SMTP relay")
|
||||
.port(config.port)
|
||||
.credentials(creds)
|
||||
.tls(tls)
|
||||
.build();
|
||||
|
||||
SmtpClient { config, mailer }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::sender::Sender for SmtpClient {
|
||||
async fn send(&self, to: &[String], subject: &str, body: &str) -> Result<(), EmailError> {
|
||||
let site_name = if self.config.site_name.is_empty() {
|
||||
self.config.from.clone()
|
||||
} else {
|
||||
self.config.site_name.clone()
|
||||
};
|
||||
|
||||
let from_header = format!("{} <{}>", site_name, self.config.from);
|
||||
|
||||
let from_addr: lettre::message::Mailbox = from_header
|
||||
.parse()
|
||||
.map_err(|e: lettre::address::AddressError| {
|
||||
EmailError::MessageBuild(e.to_string())
|
||||
})?;
|
||||
|
||||
let mut builder = Message::builder().from(from_addr);
|
||||
|
||||
if let Some(ref reply_to) = self.config.reply_to {
|
||||
let reply_addr: lettre::message::Mailbox = reply_to
|
||||
.parse()
|
||||
.map_err(|e: lettre::address::AddressError| {
|
||||
EmailError::MessageBuild(e.to_string())
|
||||
})?;
|
||||
builder = builder.reply_to(reply_addr);
|
||||
}
|
||||
|
||||
for addr in to {
|
||||
let to_addr: lettre::message::Mailbox = addr
|
||||
.parse()
|
||||
.map_err(|e: lettre::address::AddressError| {
|
||||
EmailError::MessageBuild(e.to_string())
|
||||
})?;
|
||||
builder = builder.to(to_addr);
|
||||
}
|
||||
|
||||
let message = builder
|
||||
.subject(subject)
|
||||
.header(ContentType::TEXT_HTML)
|
||||
.body(body.to_string())
|
||||
.map_err(|e| EmailError::MessageBuild(e.to_string()))?;
|
||||
|
||||
self.mailer.send(message).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
pub const DEFAULT_EMAIL_VERIFY_TEMPLATE: &str = r#"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>
|
||||
{{if eq .Type 1}}注册验证码 / Registration Verification Code{{else}}重置密码验证码 / Password
|
||||
Reset Verification Code{{end}}
|
||||
</title>
|
||||
<style>
|
||||
body { color: black; }
|
||||
.container { border-radius: 5px; width: 500px; margin: 20px auto 0; border: 1px solid #cce7ff; background-color: #f0f8ff; padding: 25px 30px; }
|
||||
.header { text-align: center; display: flex; align-items: center; justify-content: center; }
|
||||
.logo { width: 56px; height: 56px; object-fit: cover; margin-right: 10px; }
|
||||
.site-name { font-size: 18px; font-weight: bold; margin: 0; }
|
||||
.content { margin: 10px 0; font-size: 14px; }
|
||||
.greeting { font-weight: 700; margin: 5px 0; }
|
||||
.highlight { margin: 0 2px; font-weight: 700; color: #007bff; }
|
||||
.code-container { margin: 25px 0; width: 100%; background-color: #e6f2ff; height: 60px; line-height: 60px; text-align: center; font-size: 32px; font-weight: 700; color: #007bff; }
|
||||
.code { letter-spacing: 5pt; }
|
||||
.footer { border-top: #99ccff 1px solid; margin-top: 20px; padding-top: 5px; font-size: 12px; font-weight: 700; color: #777; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{.SiteLogo}}" class="logo" />
|
||||
<p class="site-name">{{.SiteName}}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
|
||||
<p>
|
||||
{{if eq .Type 1}} 感谢您注册!您的验证码是(请于<span class="highlight">{{.Expire}}</span>分钟内使用):
|
||||
<br />
|
||||
Thank you for registering! Your verification code is (please use it within <span class="highlight">{{.Expire}}</span> minutes): {{else}}
|
||||
您正在重置密码。您的验证码是(请于<span class="highlight">{{.Expire}}</span>分钟内使用):
|
||||
<br />
|
||||
You are resetting your password. Your verification code is (please use it within <span class="highlight">{{.Expire}}</span> minutes): {{end}}
|
||||
</p>
|
||||
<div class="code-container">
|
||||
<span class="code">{{.Code}}</span>
|
||||
</div>
|
||||
<p>
|
||||
如果您未请求此验证码,请忽略此邮件。<br />If you did not request this code, please ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
pub const DEFAULT_MAINTENANCE_EMAIL_TEMPLATE: &str = r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>系统维护通知 / System Maintenance Notice</title>
|
||||
<style>
|
||||
body { color: black; }
|
||||
.container { border-radius: 5px; width: 500px; margin: 20px auto 0; border: 1px solid #cce7ff; background-color: #f0f8ff; padding: 25px 30px; }
|
||||
.header { text-align: center; display: flex; align-items: center; justify-content: center; }
|
||||
.logo { width: 56px; height: 56px; object-fit: cover; margin-right: 10px; }
|
||||
.site-name { font-size: 18px; font-weight: bold; margin: 0; }
|
||||
.content { margin: 20px 0; font-size: 14px; }
|
||||
.greeting { font-weight: 700; margin: 5px 0; }
|
||||
.highlight { margin: 0 2px; font-weight: 700; color: #007bff; }
|
||||
.footer { border-top: #99ccff 1px solid; margin-top: 20px; padding-top: 5px; font-size: 12px; font-weight: 700; color: #777; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{.SiteLogo}}" class="logo" />
|
||||
<p class="site-name">{{.SiteName}}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
|
||||
<p>
|
||||
我们计划在<span class="highlight">{{.MaintenanceDate}}</span>进行系统维护,预计维护时间为<span class="highlight">{{.MaintenanceTime}}</span>。在此期间,您可能会遇到服务中断或无法访问的情况。
|
||||
<br />
|
||||
We will be performing system maintenance on <span class="highlight">{{.MaintenanceDate}}</span>, and the expected maintenance period is <span class="highlight">{{.MaintenanceTime}}</span>. During this time, you may experience service interruptions or unavailability.
|
||||
</p>
|
||||
<p>
|
||||
维护完成后,系统将自动恢复。如果您有任何问题,请随时联系我们的支持团队。
|
||||
<br />
|
||||
The system will resume automatically once the maintenance is completed. If you have any questions, please feel free to contact our support team.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
pub const DEFAULT_EXPIRATION_EMAIL_TEMPLATE: &str = r#"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>服务到期通知 / Service Expiration Notice</title>
|
||||
<style>
|
||||
body { color: black; }
|
||||
.container { border-radius: 5px; width: 500px; margin: 20px auto 0; border: 1px solid #cce7ff; background-color: #f0f8ff; padding: 25px 30px; }
|
||||
.header { text-align: center; display: flex; align-items: center; justify-content: center; }
|
||||
.logo { width: 56px; height: 56px; object-fit: cover; margin-right: 10px; }
|
||||
.site-name { font-size: 18px; font-weight: bold; margin: 0; }
|
||||
.content { margin: 20px 0; font-size: 14px; }
|
||||
.greeting { font-weight: 700; margin: 5px 0; }
|
||||
.highlight { margin: 0 2px; font-weight: 700; color: #007bff; }
|
||||
.footer { border-top: #99ccff 1px solid; margin-top: 20px; padding-top: 5px; font-size: 12px; font-weight: 700; color: #777; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{.SiteLogo}}" class="logo" />
|
||||
<p class="site-name">{{.SiteName}}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
|
||||
<p>
|
||||
您的服务即将在<span class="highlight">{{.ExpireDate}}</span>到期,请及时续费以保证服务不间断。
|
||||
<br />
|
||||
Your service is set to expire on <span class="highlight">{{.ExpireDate}}</span>. Please renew your subscription to avoid service interruptions.
|
||||
</p>
|
||||
<p>
|
||||
如需帮助,请联系客服团队。感谢您的支持!
|
||||
<br />
|
||||
If you need assistance, please contact our support team. Thank you for your continued support!
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
pub const DEFAULT_TRAFFIC_EXCEED_EMAIL_TEMPLATE: &str = r#"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>流量用尽通知 / Traffic Exhausted Notice</title>
|
||||
<style>
|
||||
.container { border-radius: 5px; width: 500px; margin: 20px auto 0; border: 1px solid #cce7ff; background-color: #f0f8ff; padding: 25px 30px; }
|
||||
.header { text-align: center; display: flex; align-items: center; justify-content: center; }
|
||||
.logo { width: 56px; height: 56px; object-fit: cover; margin-right: 10px; }
|
||||
.site-name { font-size: 18px; font-weight: bold; margin: 0; }
|
||||
.content { margin: 20px 0; font-size: 14px; }
|
||||
.greeting { font-weight: 700; margin: 5px 0; }
|
||||
.highlight { color: #007bff; }
|
||||
.footer { border-top: #99ccff 1px solid; margin-top: 20px; padding-top: 5px; font-size: 12px; font-weight: 700; color: #777; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{.SiteLogo}}" class="logo" />
|
||||
<p class="site-name">{{.SiteName}}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
|
||||
<p>
|
||||
您的流量已经用尽,请及时购买流量以继续使用我们的服务。
|
||||
<br />
|
||||
Your traffic has been exhausted. Please purchase additional traffic to continue using our service.
|
||||
</p>
|
||||
<p>
|
||||
如需帮助,请联系客服团队。感谢您的支持!
|
||||
<br />
|
||||
If you need assistance, please contact our support team. Thank you for your continued support!
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
@@ -0,0 +1,263 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::sender::Sender;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ErrorInfo {
|
||||
pub error: String,
|
||||
pub email: String,
|
||||
pub time: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WorkerStatus {
|
||||
Idle = 0,
|
||||
Running = 1,
|
||||
Completed = 2,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct EmailScope {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: i16,
|
||||
#[serde(default)]
|
||||
pub register_start_time: i64,
|
||||
#[serde(default)]
|
||||
pub register_end_time: i64,
|
||||
#[serde(default)]
|
||||
pub recipients: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub additional: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub scheduled: i64,
|
||||
#[serde(default)]
|
||||
pub interval: i16,
|
||||
#[serde(default)]
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct EmailContent {
|
||||
pub subject: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskInfo {
|
||||
pub id: i64,
|
||||
pub type_: i16,
|
||||
pub scope: String,
|
||||
pub content: String,
|
||||
pub status: i16,
|
||||
pub errors: String,
|
||||
pub total: i64,
|
||||
pub current: i64,
|
||||
}
|
||||
|
||||
pub struct Worker {
|
||||
id: i64,
|
||||
repo: Arc<dyn crate::manager::TaskRepo>,
|
||||
sender: Arc<dyn Sender>,
|
||||
status: Arc<Mutex<WorkerStatus>>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub fn new(
|
||||
id: i64,
|
||||
repo: Arc<dyn crate::manager::TaskRepo>,
|
||||
sender: Arc<dyn Sender>,
|
||||
) -> Self {
|
||||
Worker {
|
||||
id,
|
||||
repo,
|
||||
sender,
|
||||
status: Arc::new(Mutex::new(WorkerStatus::Idle)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> i64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub async fn is_running(&self) -> WorkerStatus {
|
||||
*self.status.lock().await
|
||||
}
|
||||
|
||||
pub async fn start(&self) {
|
||||
let task_info = match self.repo.find_one(self.id).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Batch Send Email: Failed to find task, task_id={}, error={}",
|
||||
self.id,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if task_info.status != 0 {
|
||||
tracing::error!(
|
||||
"Batch Send Email: Task already completed or in progress, task_id={}",
|
||||
self.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let scope: EmailScope = match serde_json::from_str(&task_info.scope) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Batch Send Email: Failed to parse task scope, task_id={}, error={}",
|
||||
self.id,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if scope.recipients.is_empty() && scope.additional.is_empty() {
|
||||
tracing::error!(
|
||||
"Batch Send Email: No recipients or additional emails provided, task_id={}",
|
||||
self.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let content: EmailContent = match serde_json::from_str(&task_info.content) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Batch Send Email: Failed to parse task content, task_id={}, error={}",
|
||||
self.id,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut status = self.status.lock().await;
|
||||
*status = WorkerStatus::Running;
|
||||
}
|
||||
|
||||
let mut recipients = scope.recipients.clone();
|
||||
recipients.extend(scope.additional.clone());
|
||||
remove_duplicates_and_empty(&mut recipients);
|
||||
|
||||
if recipients.is_empty() {
|
||||
tracing::error!(
|
||||
"Batch Send Email: No valid recipients found, task_id={}",
|
||||
self.id
|
||||
);
|
||||
let mut status = self.status.lock().await;
|
||||
*status = WorkerStatus::Completed;
|
||||
return;
|
||||
}
|
||||
|
||||
let interval = if scope.interval == 0 {
|
||||
Duration::from_secs(1)
|
||||
} else {
|
||||
Duration::from_secs(scope.interval as u64)
|
||||
};
|
||||
|
||||
let mut errors: Vec<ErrorInfo> = Vec::new();
|
||||
let mut count: i64 = 0;
|
||||
|
||||
for recipient in &recipients {
|
||||
if self.repo.is_cancelled(self.id) {
|
||||
tracing::info!(
|
||||
"Batch Send Email: Worker stopped by cancellation, task_id={}",
|
||||
self.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if task_info.status == 0 {
|
||||
// mark as in-progress via repo
|
||||
let _ = self.repo.update_status(self.id, 1).await;
|
||||
}
|
||||
|
||||
if let Err(e) = self
|
||||
.sender
|
||||
.send(std::slice::from_ref(recipient), &content.subject, &content.content)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Batch Send Email: Failed to send email, task_id={}, recipient={}, error={}",
|
||||
self.id,
|
||||
recipient,
|
||||
e
|
||||
);
|
||||
errors.push(ErrorInfo {
|
||||
error: e.to_string(),
|
||||
email: recipient.clone(),
|
||||
time: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64,
|
||||
});
|
||||
}
|
||||
|
||||
count += 1;
|
||||
|
||||
let mut updated = task_info.clone();
|
||||
updated.current = count;
|
||||
updated.errors = serde_json::to_string(&errors).unwrap_or_default();
|
||||
|
||||
if let Err(e) = self.repo.update(&updated).await {
|
||||
tracing::error!(
|
||||
"Batch Send Email: Failed to update task progress, task_id={}, error={}",
|
||||
self.id,
|
||||
e
|
||||
);
|
||||
let mut status = self.status.lock().await;
|
||||
*status = WorkerStatus::Completed;
|
||||
}
|
||||
|
||||
sleep(interval).await;
|
||||
}
|
||||
|
||||
let mut status = self.status.lock().await;
|
||||
*status = WorkerStatus::Completed;
|
||||
|
||||
let mut finalized = task_info.clone();
|
||||
finalized.status = 2;
|
||||
finalized.current = count;
|
||||
finalized.errors = serde_json::to_string(&errors).unwrap_or_default();
|
||||
|
||||
match self.repo.update(&finalized).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"Batch Send Email: Task completed successfully, task_id={}, total_sent={}",
|
||||
self.id,
|
||||
count
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Batch Send Email: Failed to finalize task, task_id={}, error={}",
|
||||
self.id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_duplicates_and_empty(items: &mut Vec<String>) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
items.retain(|item| {
|
||||
if item.is_empty() || seen.contains(item) {
|
||||
false
|
||||
} else {
|
||||
seen.insert(item.clone());
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "ip"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", features = ["json", "brotli", "gzip"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tokio = { version = "1", features = ["net"] }
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const IPINFO: &str = "ipinfo.io";
|
||||
const IPAPI: &str = "ipapi.co";
|
||||
const IPBASE: &str = "api.ipbase.com";
|
||||
const IPWHOIS: &str = "ipwhois.app";
|
||||
|
||||
const SERVICES: &[&str] = &[IPBASE, IPAPI, IPWHOIS, IPINFO];
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IpError {
|
||||
#[error("DNS resolution failed for {0}")]
|
||||
DnsResolutionFailed(String),
|
||||
#[error("HTTP request failed: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("JSON parsing failed: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("all geolocation services failed")]
|
||||
AllServicesFailed,
|
||||
}
|
||||
|
||||
pub async fn resolve_ip(input: &str) -> Result<Vec<String>, IpError> {
|
||||
if let Ok(ip) = input.parse::<IpAddr>() {
|
||||
return Ok(vec![ip.to_string()]);
|
||||
}
|
||||
|
||||
let addrs = tokio::net::lookup_host(input).await.map_err(|_| {
|
||||
IpError::DnsResolutionFailed(input.to_string())
|
||||
})?;
|
||||
|
||||
let ips: Vec<String> = addrs.map(|sa| sa.ip().to_string()).collect();
|
||||
if ips.is_empty() {
|
||||
return Err(IpError::DnsResolutionFailed(input.to_string()));
|
||||
}
|
||||
Ok(ips)
|
||||
}
|
||||
|
||||
pub async fn get_region_by_ip(ip: &str) -> Result<GeoLocationResponse, IpError> {
|
||||
let client = new_http_client();
|
||||
|
||||
for service in SERVICES {
|
||||
match fetch_geolocation(&client, service, ip).await {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch geolocation from {}: {:?}", service, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(IpError::AllServicesFailed)
|
||||
}
|
||||
|
||||
fn new_http_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent("Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0")
|
||||
.build()
|
||||
.expect("Failed to build reqwest::Client")
|
||||
}
|
||||
|
||||
async fn fetch_geolocation(
|
||||
client: &reqwest::Client,
|
||||
service: &str,
|
||||
ip: &str,
|
||||
) -> Result<GeoLocationResponse, IpError> {
|
||||
let api_url = match service {
|
||||
IPINFO => format!("https://ipinfo.io/{}/json", ip),
|
||||
IPAPI => format!("https://ipapi.co/{}/json", ip),
|
||||
IPBASE => format!("https://api.ipbase.com/v1/json/{}", ip),
|
||||
IPWHOIS => format!("https://ipwhois.app/json/{}", ip),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(&api_url)
|
||||
.header("Host", service)
|
||||
.header("Accept", "application/json, text/html, application/xhtml+xml, */*;q=0.8")
|
||||
.header("Accept-Language", "en-US,en;q=0.5")
|
||||
.header("Accept-Encoding", "gzip, deflate, br, zstd")
|
||||
.header("Connection", "keep-alive")
|
||||
.header("Upgrade-Insecure-Requests", "1")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let bytes = resp.bytes().await?;
|
||||
let mut location: GeoLocationResponse = serde_json::from_slice(&bytes)?;
|
||||
|
||||
if location.country.is_empty() {
|
||||
location.country = location.country_name.clone();
|
||||
}
|
||||
|
||||
if !location.loc.is_empty() {
|
||||
if let Some((lat, lon)) = location.loc.split_once(',') {
|
||||
location.latitude = lat.trim().to_string();
|
||||
location.longitude = lon.trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(location)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct GeoLocationResponse {
|
||||
pub country: String,
|
||||
pub country_name: String,
|
||||
pub region: String,
|
||||
pub city: String,
|
||||
pub latitude: String,
|
||||
pub longitude: String,
|
||||
pub loc: String,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "jwt"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
jsonwebtoken = "9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -0,0 +1,58 @@
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub exp: i64,
|
||||
pub iat: i64,
|
||||
#[serde(rename = "UserId")]
|
||||
pub user_id: i64,
|
||||
#[serde(rename = "SessionId")]
|
||||
pub session_id: String,
|
||||
#[serde(rename = "LoginType")]
|
||||
pub login_type: String,
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
pub fn new(user_id: i64, session_id: String, login_type: String) -> (Self, i64) {
|
||||
let now = chrono_now();
|
||||
let seconds = 604800;
|
||||
(
|
||||
Self {
|
||||
iat: now,
|
||||
exp: now + seconds,
|
||||
user_id,
|
||||
session_id,
|
||||
login_type,
|
||||
},
|
||||
seconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn chrono_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
pub fn generate_token(claims: &Claims, secret: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
encode(
|
||||
&Header::default(),
|
||||
claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn validate_token(
|
||||
token: &str,
|
||||
secret: &str,
|
||||
) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "oauth"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
arctic-oauth = { version = "0.3.0", features = ["apple", "google"] }
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
@@ -0,0 +1,24 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GoogleConfig {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub redirect_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppleConfig {
|
||||
pub team_id: String,
|
||||
pub key_id: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub redirect_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TelegramConfig {
|
||||
pub bot_token: String,
|
||||
pub enable_notify: Option<bool>,
|
||||
pub webhook_domain: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OAuthError {
|
||||
Config(String),
|
||||
Telegram(String),
|
||||
Arctic(arctic_oauth::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for OAuthError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
OAuthError::Config(msg) => write!(f, "OAuth config error: {}", msg),
|
||||
OAuthError::Telegram(msg) => write!(f, "Telegram OAuth error: {}", msg),
|
||||
OAuthError::Arctic(e) => write!(f, "OAuth error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OAuthError {}
|
||||
|
||||
impl From<arctic_oauth::Error> for OAuthError {
|
||||
fn from(e: arctic_oauth::Error) -> Self {
|
||||
OAuthError::Arctic(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! OAuth 2.0 authentication library wrapping `arctic-oauth` with
|
||||
//! config types matching the database schema, plus Telegram Login support.
|
||||
//!
|
||||
//! # Providers
|
||||
//!
|
||||
//! | Provider | Standard OAuth 2.0 | PKCE | Tokens |
|
||||
//! |-----------|-------------------|----------|---------------|
|
||||
//! | Google | ✅ (arctic-oauth) | Required | access + refresh + id_token |
|
||||
//! | Apple | ✅ (arctic-oauth) | None | access + id_token |
|
||||
//! | Telegram | ⚠️ Custom HMAC | N/A | N/A (stateless) |
|
||||
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod telegram;
|
||||
|
||||
// Re-export arctic-oauth core types + providers
|
||||
pub use arctic_oauth::{
|
||||
create_code_challenge, decode_id_token, generate_code_verifier, generate_state,
|
||||
Apple, AppleOptions, Google, GoogleOptions, OAuth2Tokens,
|
||||
};
|
||||
pub use arctic_oauth::{CodeChallengeMethod, Error as ArcticError};
|
||||
pub use config::{AppleConfig, GoogleConfig, TelegramConfig};
|
||||
pub use error::OAuthError;
|
||||
pub use telegram::{parse_and_validate_auth_data, parse_base64_and_validate, validate_auth_data, AuthData};
|
||||
|
||||
/// Unified user info extracted from any OAuth provider.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OAuthUserInfo {
|
||||
pub open_id: String,
|
||||
pub email: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub picture: Option<String>,
|
||||
}
|
||||
|
||||
impl OAuthUserInfo {
|
||||
pub fn from_google(tokens: &OAuth2Tokens) -> Result<Self, OAuthError> {
|
||||
let id_token = tokens.id_token()?;
|
||||
let claims = decode_id_token(id_token)?;
|
||||
let open_id = claims
|
||||
.get("sub")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let email = claims.get("email").and_then(|v| v.as_str()).map(String::from);
|
||||
let name = claims.get("name").and_then(|v| v.as_str()).map(String::from);
|
||||
let picture = claims
|
||||
.get("picture")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
Ok(Self {
|
||||
open_id,
|
||||
email,
|
||||
name,
|
||||
picture,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_apple(tokens: &OAuth2Tokens) -> Result<Self, OAuthError> {
|
||||
let id_token = tokens.id_token()?;
|
||||
let claims = decode_id_token(id_token)?;
|
||||
let open_id = claims
|
||||
.get("sub")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let email = claims.get("email").and_then(|v| v.as_str()).map(String::from);
|
||||
let name = None;
|
||||
let picture = None;
|
||||
Ok(Self {
|
||||
open_id,
|
||||
email,
|
||||
name,
|
||||
picture,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_telegram(data: &AuthData) -> Self {
|
||||
let open_id = data.id.to_string();
|
||||
let name = Some(
|
||||
[data.first_name.as_deref(), data.last_name.as_deref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
)
|
||||
.filter(|s| !s.is_empty());
|
||||
Self {
|
||||
open_id,
|
||||
email: None,
|
||||
name,
|
||||
picture: data.photo_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_id(&self) -> &str {
|
||||
&self.open_id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::OAuthError;
|
||||
|
||||
const AUTH_DATE_TTL_SECS: i64 = 86400;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AuthData {
|
||||
pub id: i64,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub photo_url: Option<String>,
|
||||
pub auth_date: i64,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
pub type TelegramUserInfo = AuthData;
|
||||
|
||||
fn check_string(data: &AuthData) -> String {
|
||||
let mut pairs: Vec<(String, String)> = Vec::new();
|
||||
|
||||
pairs.push(("id".to_string(), data.id.to_string()));
|
||||
if let Some(v) = &data.first_name {
|
||||
pairs.push(("first_name".to_string(), v.clone()));
|
||||
}
|
||||
if let Some(v) = &data.last_name {
|
||||
pairs.push(("last_name".to_string(), v.clone()));
|
||||
}
|
||||
if let Some(v) = &data.username {
|
||||
pairs.push(("username".to_string(), v.clone()));
|
||||
}
|
||||
if let Some(v) = &data.photo_url {
|
||||
pairs.push(("photo_url".to_string(), v.clone()));
|
||||
}
|
||||
pairs.push(("auth_date".to_string(), data.auth_date.to_string()));
|
||||
|
||||
pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn validate_auth_data(data: &AuthData, bot_token: &[u8]) -> Result<(), OAuthError> {
|
||||
if bot_token.is_empty() {
|
||||
return Err(OAuthError::Telegram(
|
||||
"telegram bot token is not provided".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| OAuthError::Telegram(format!("system clock error: {}", e)))?
|
||||
.as_secs() as i64;
|
||||
|
||||
if now - data.auth_date > AUTH_DATE_TTL_SECS {
|
||||
return Err(OAuthError::Telegram("auth date is expired".into()));
|
||||
}
|
||||
|
||||
let check_str = check_string(data);
|
||||
|
||||
let key = sha2::Sha256::digest(bot_token);
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&key)
|
||||
.map_err(|e| OAuthError::Telegram(format!("HMAC key error: {}", e)))?;
|
||||
mac.update(check_str.as_bytes());
|
||||
let computed = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
if data.hash != computed {
|
||||
return Err(OAuthError::Telegram("hash is not valid".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_and_validate_auth_data(
|
||||
json_bytes: &[u8],
|
||||
bot_token: &[u8],
|
||||
) -> Result<AuthData, OAuthError> {
|
||||
let data: AuthData = serde_json::from_slice(json_bytes)
|
||||
.map_err(|e| OAuthError::Telegram(format!("json parse error: {}", e)))?;
|
||||
validate_auth_data(&data, bot_token)?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub fn parse_base64_and_validate(
|
||||
base64_str: &str,
|
||||
bot_token: &[u8],
|
||||
) -> Result<AuthData, OAuthError> {
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(base64_str.as_bytes())
|
||||
.or_else(|_| {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(base64_str.as_bytes())
|
||||
})
|
||||
.map_err(|e| OAuthError::Telegram(format!("base64 decode error: {}", e)))?;
|
||||
|
||||
parse_and_validate_auth_data(&decoded, bot_token)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "password"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
pbkdf2 = "0.12"
|
||||
sha2 = "0.10"
|
||||
md5 = "0.7"
|
||||
bcrypt = "0.15"
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
@@ -0,0 +1,231 @@
|
||||
//! 密码加密与验证工具。
|
||||
//!
|
||||
//! 从 `/root/project-moth/server/pkg/tool/encryption.go` 迁移而来。
|
||||
//! 提供密码哈希(PBKDF2-SHA512)、MD5 编码以及多算法验证。
|
||||
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
|
||||
const PBKDF2_SALT_LEN: usize = 16;
|
||||
const PBKDF2_ITERATIONS: u32 = 100;
|
||||
const PBKDF2_KEY_LEN: usize = 32;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PasswordError {
|
||||
HashError(String),
|
||||
ParseError(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PasswordError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PasswordError::HashError(msg) => write!(f, "Hash error: {}", msg),
|
||||
PasswordError::ParseError(msg) => write!(f, "Parse error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PasswordError {}
|
||||
|
||||
/// 使用 PBKDF2-SHA512 对密码进行加盐编码。
|
||||
///
|
||||
/// 返回格式化字符串:`$pbkdf2-sha512$<salt>$<hash>`。
|
||||
///
|
||||
/// # 示例
|
||||
/// ```
|
||||
/// let encoded = password::encode_password("mypassword").unwrap();
|
||||
/// assert!(encoded.starts_with("$pbkdf2-sha512$"));
|
||||
/// ```
|
||||
pub fn encode_password(password: &str) -> Result<String, PasswordError> {
|
||||
use rand::Rng;
|
||||
|
||||
// 生成随机盐值
|
||||
let mut rng = rand::thread_rng();
|
||||
let salt: Vec<u8> = (0..PBKDF2_SALT_LEN).map(|_| rng.gen()).collect();
|
||||
|
||||
// 使用 PBKDF2-HMAC-SHA512 对密码进行哈希
|
||||
let mut key = vec![0u8; PBKDF2_KEY_LEN];
|
||||
pbkdf2_hmac::<Sha512>(password.as_bytes(), &salt, PBKDF2_ITERATIONS, &mut key);
|
||||
|
||||
// 将盐值和密钥编码为十六进制
|
||||
let salt_hex = hex::encode(&salt);
|
||||
let key_hex = hex::encode(&key);
|
||||
|
||||
Ok(format!("$pbkdf2-sha512${}${}", salt_hex, key_hex))
|
||||
}
|
||||
|
||||
/// 验证密码与编码后的密码哈希是否匹配。
|
||||
///
|
||||
/// 预期格式:`$pbkdf2-sha512$<salt>$<hash>`。
|
||||
///
|
||||
/// # 示例
|
||||
/// ```
|
||||
/// let encoded = password::encode_password("mypassword").unwrap();
|
||||
/// assert!(password::verify_password("mypassword", &encoded));
|
||||
/// assert!(!password::verify_password("wrongpass", &encoded));
|
||||
/// ```
|
||||
pub fn verify_password(password: &str, encoded: &str) -> bool {
|
||||
let parts: Vec<&str> = encoded.split('$').collect();
|
||||
if parts.len() < 4 || parts[1] != "pbkdf2-sha512" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let salt_hex = parts[2];
|
||||
let expected_hash_hex = parts[3];
|
||||
|
||||
// 从十六进制解码盐值
|
||||
let salt = match hex::decode(salt_hex) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
// 计算哈希值
|
||||
let mut key = vec![0u8; PBKDF2_KEY_LEN];
|
||||
pbkdf2_hmac::<Sha512>(password.as_bytes(), &salt, PBKDF2_ITERATIONS, &mut key);
|
||||
|
||||
// 与期望的哈希值进行比较
|
||||
let computed_hash_hex = hex::encode(&key);
|
||||
computed_hash_hex == expected_hash_hex
|
||||
}
|
||||
|
||||
/// 计算输入字符串的 MD5 哈希值。
|
||||
///
|
||||
/// # 参数
|
||||
/// * `s` - 输入字符串
|
||||
/// * `uppercase` - 若为 true 则返回大写十六进制,否则返回小写
|
||||
///
|
||||
/// # 示例
|
||||
/// ```
|
||||
/// let hash = password::md5_encode("hello", false);
|
||||
/// assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592");
|
||||
/// ```
|
||||
pub fn md5_encode(s: &str, uppercase: bool) -> String {
|
||||
let digest = md5::compute(s.as_bytes());
|
||||
let result = format!("{:x}", digest);
|
||||
if uppercase {
|
||||
result.to_uppercase()
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用多种算法验证密码。
|
||||
///
|
||||
/// 支持的算法:
|
||||
/// - `"md5"`:简单 MD5 哈希
|
||||
/// - `"sha256"`:简单 SHA-256 哈希
|
||||
/// - `"md5salt"`:MD5(密码 + 盐值)
|
||||
/// - `"sha256salt"`:SHA-256(密码 + 盐值),由 SSPanel 使用
|
||||
/// - `"default"`:PBKDF2-SHA512(PPanel 默认)
|
||||
/// - `"bcrypt"`:Bcrypt 哈希
|
||||
///
|
||||
/// # 参数
|
||||
/// * `algo` - 算法名称
|
||||
/// * `salt` - 盐值字符串(用于 `*salt` 算法)
|
||||
/// * `password` - 明文密码
|
||||
/// * `hash` - 期望的哈希值
|
||||
///
|
||||
/// # 示例
|
||||
/// ```
|
||||
/// let result = password::multi_password_verify("md5", "", "hello", "5d41402abc4b2a76b9719d911017c592");
|
||||
/// assert!(result);
|
||||
/// ```
|
||||
pub fn multi_password_verify(algo: &str, salt: &str, password: &str, hash: &str) -> bool {
|
||||
match algo {
|
||||
"md5" => {
|
||||
let digest = md5::compute(password.as_bytes());
|
||||
let computed = format!("{:x}", digest);
|
||||
computed == hash
|
||||
}
|
||||
"sha256" => {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(password.as_bytes());
|
||||
let computed = hex::encode(hasher.finalize());
|
||||
computed == hash
|
||||
}
|
||||
"md5salt" => {
|
||||
let input = format!("{}{}", password, salt);
|
||||
let digest = md5::compute(input.as_bytes());
|
||||
let computed = format!("{:x}", digest);
|
||||
computed == hash
|
||||
}
|
||||
"sha256salt" => {
|
||||
let input = format!("{}{}", password, salt);
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(input.as_bytes());
|
||||
let computed = hex::encode(hasher.finalize());
|
||||
computed == hash
|
||||
}
|
||||
"default" => verify_password(password, hash),
|
||||
"bcrypt" => bcrypt::verify(password, hash).unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encode_and_verify_password() {
|
||||
let password = "test_password_123";
|
||||
let encoded = encode_password(password).unwrap();
|
||||
|
||||
eprintln!("Encoded password: {}", encoded);
|
||||
|
||||
assert!(encoded.starts_with("$pbkdf2-sha512$"));
|
||||
assert!(verify_password(password, &encoded));
|
||||
assert!(!verify_password("wrong_password", &encoded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_md5_encode() {
|
||||
let input = "hello";
|
||||
let lowercase = md5_encode(input, false);
|
||||
let uppercase = md5_encode(input, true);
|
||||
|
||||
assert_eq!(lowercase, "5d41402abc4b2a76b9719d911017c592");
|
||||
assert_eq!(uppercase, "5D41402ABC4B2A76B9719D911017C592");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_password_verify_md5() {
|
||||
let password = "hello";
|
||||
let hash = "5d41402abc4b2a76b9719d911017c592";
|
||||
assert!(multi_password_verify("md5", "", password, hash));
|
||||
assert!(!multi_password_verify("md5", "", "wrong", hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_password_verify_sha256() {
|
||||
let password = "hello";
|
||||
let hash = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
assert!(multi_password_verify("sha256", "", password, hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_password_verify_md5salt() {
|
||||
let password = "hello";
|
||||
let salt = "world";
|
||||
// MD5("helloworld") = fc5e038d38a57032085441e7fe7010b0
|
||||
let hash = "fc5e038d38a57032085441e7fe7010b0";
|
||||
assert!(multi_password_verify("md5salt", salt, password, hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_password_verify_bcrypt() {
|
||||
let password = "test123";
|
||||
// 预先为 "test123" 生成的 bcrypt 哈希值
|
||||
let hash = bcrypt::hash(password, 4).unwrap();
|
||||
assert!(multi_password_verify("bcrypt", "", password, &hash));
|
||||
assert!(!multi_password_verify("bcrypt", "", "wrong", &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_password_verify_default() {
|
||||
let password = "test123";
|
||||
let encoded = encode_password(password).unwrap();
|
||||
assert!(multi_password_verify("default", "", password, &encoded));
|
||||
assert!(!multi_password_verify("default", "", "wrong", &encoded));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "payment"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
alipay_sdk_rust = "1"
|
||||
stripe = { version = "0.40", package = "async-stripe", features = ["runtime-tokio-hyper"] }
|
||||
md5 = "0.7"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_urlencoded = "0.7"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
url = "2"
|
||||
@@ -0,0 +1,162 @@
|
||||
use alipay_sdk_rust::{
|
||||
biz::{BizContenter, TradePrecreateBiz, TradeQueryBiz},
|
||||
pay::{Payer, PayClient},
|
||||
};
|
||||
|
||||
use crate::error::PaymentError;
|
||||
use crate::types::Cents;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub app_id: String,
|
||||
pub private_key: String,
|
||||
pub public_key: String,
|
||||
pub invoice_name: String,
|
||||
pub notify_url: String,
|
||||
pub sandbox: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OrderStatus {
|
||||
Success,
|
||||
Pending,
|
||||
Closed,
|
||||
Finished,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub struct Notification {
|
||||
pub order_no: String,
|
||||
pub amount: Cents,
|
||||
pub status: OrderStatus,
|
||||
}
|
||||
|
||||
pub struct Provider {
|
||||
client: Box<dyn Payer>,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
pub fn new(config: Config) -> Result<Self, PaymentError> {
|
||||
let api_url = if config.sandbox {
|
||||
"https://openapi-sandbox.dl.alipaydev.com/gateway.do"
|
||||
} else {
|
||||
"https://openapi.dl.alipaydev.com/gateway.do"
|
||||
};
|
||||
|
||||
let app_id = config.app_id.clone();
|
||||
let private_key = config.private_key.clone();
|
||||
let public_key = config.public_key.clone();
|
||||
|
||||
let client: Box<dyn Payer> = Box::new(
|
||||
PayClient::builder()
|
||||
.api_url(api_url)
|
||||
.app_id(&app_id)
|
||||
.private_key(&private_key)
|
||||
.public_key(&public_key)
|
||||
.sign_type_rsa2()
|
||||
.charset_utf8()
|
||||
.format_json()
|
||||
.version_1_0()
|
||||
.build()
|
||||
.map_err(|e| PaymentError::Config(format!("Alipay config error: {e}")))?,
|
||||
);
|
||||
|
||||
Ok(Provider { client, config })
|
||||
}
|
||||
|
||||
pub fn pre_create_trade(&self, order_no: &str, amount: Cents) -> Result<String, PaymentError> {
|
||||
let mut biz = TradePrecreateBiz::new();
|
||||
biz.set_out_trade_no(order_no.to_string().into());
|
||||
biz.set_total_amount(amount.to_yuan_string().into());
|
||||
biz.set_subject(self.config.invoice_name.clone().into());
|
||||
biz.set("notify_url", self.config.notify_url.clone().into());
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.trade_precreate(&biz)
|
||||
.map_err(|e| PaymentError::Alipay(e.to_string()))?;
|
||||
|
||||
if resp.response.code.as_deref() != Some("10000") {
|
||||
return Err(PaymentError::Alipay(
|
||||
resp.response
|
||||
.sub_msg
|
||||
.unwrap_or_else(|| "unknown alipay error".into()),
|
||||
));
|
||||
}
|
||||
|
||||
resp.response
|
||||
.qr_code
|
||||
.ok_or_else(|| PaymentError::Alipay("QR code not returned".into()))
|
||||
}
|
||||
|
||||
pub fn query_trade(&self, order_no: &str) -> Result<OrderStatus, PaymentError> {
|
||||
let mut biz = TradeQueryBiz::new();
|
||||
biz.set_out_trade_no(order_no.to_string().into());
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.trade_query(&biz)
|
||||
.map_err(|e| PaymentError::Alipay(e.to_string()))?;
|
||||
|
||||
match resp.response.trade_status.as_deref() {
|
||||
Some("TRADE_SUCCESS") | Some("TRADE_FINISHED") => Ok(OrderStatus::Success),
|
||||
Some("WAIT_BUYER_PAY") => Ok(OrderStatus::Pending),
|
||||
Some("TRADE_CLOSED") => Ok(OrderStatus::Closed),
|
||||
Some(s) => Ok(OrderStatus::Error(s.into())),
|
||||
None => Ok(OrderStatus::Error("no trade status".into())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_notification(&self, body: &[u8]) -> Result<Notification, PaymentError> {
|
||||
let verified = self
|
||||
.client
|
||||
.async_verify_sign(body)
|
||||
.map_err(|e| PaymentError::Alipay(format!("notification verify failed: {e}")))?;
|
||||
|
||||
if !verified {
|
||||
return Err(PaymentError::Alipay(
|
||||
"notification sign verification failed".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_slice(body)
|
||||
.map_err(|e| PaymentError::Alipay(format!("invalid notify body: {e}")))?;
|
||||
|
||||
let response = parsed
|
||||
.as_object()
|
||||
.and_then(|m| m.values().next())
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| PaymentError::Alipay("cannot parse notification".into()))?;
|
||||
|
||||
let trade_status = response
|
||||
.get("trade_status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let out_trade_no = response
|
||||
.get("out_trade_no")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let total_amount = response
|
||||
.get("total_amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0");
|
||||
|
||||
let status = match trade_status {
|
||||
"TRADE_SUCCESS" => OrderStatus::Success,
|
||||
"WAIT_BUYER_PAY" => OrderStatus::Pending,
|
||||
"TRADE_CLOSED" => OrderStatus::Closed,
|
||||
"TRADE_FINISHED" => OrderStatus::Finished,
|
||||
s => OrderStatus::Error(s.into()),
|
||||
};
|
||||
|
||||
let amount = Cents::from_yuan(total_amount)
|
||||
.map_err(|e| PaymentError::Alipay(format!("Invalid amount: {e}")))?;
|
||||
|
||||
Ok(Notification {
|
||||
order_no: out_trade_no.into(),
|
||||
amount,
|
||||
status,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::error::PaymentError;
|
||||
use crate::types::Cents;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub pid: String,
|
||||
pub url: String,
|
||||
pub key: String,
|
||||
pub pay_type: String,
|
||||
}
|
||||
|
||||
pub struct Order {
|
||||
pub name: String,
|
||||
pub order_no: String,
|
||||
pub amount: Cents,
|
||||
pub sign_type: String,
|
||||
pub notify_url: String,
|
||||
pub return_url: String,
|
||||
}
|
||||
|
||||
pub struct Provider {
|
||||
pub pid: String,
|
||||
pub url: String,
|
||||
pub key: String,
|
||||
pub pay_type: String,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Provider {
|
||||
pid: config.pid,
|
||||
url: config.url,
|
||||
key: config.key,
|
||||
pay_type: config.pay_type,
|
||||
}
|
||||
}
|
||||
|
||||
fn params_map(&self, order: &Order) -> BTreeMap<&str, String> {
|
||||
let mut m = BTreeMap::new();
|
||||
m.insert("pid", self.pid.clone());
|
||||
m.insert("type", self.pay_type.clone());
|
||||
m.insert("out_trade_no", order.order_no.clone());
|
||||
m.insert("money", order.amount.to_yuan_string());
|
||||
m.insert("name", order.name.clone());
|
||||
m.insert("notify_url", order.notify_url.clone());
|
||||
m.insert("return_url", order.return_url.clone());
|
||||
m
|
||||
}
|
||||
|
||||
fn create_sign(&self, params: &BTreeMap<&str, String>) -> String {
|
||||
let query: String = params
|
||||
.iter()
|
||||
.filter(|(k, v)| !v.is_empty() && **k != "sign" && **k != "sign_type")
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
let text = format!("{}{}", query, self.key);
|
||||
format!("{:x}", md5::compute(text))
|
||||
}
|
||||
|
||||
pub fn create_pay_url(&self, order: &Order) -> Result<String, PaymentError> {
|
||||
let params = self.params_map(order);
|
||||
let sign = self.create_sign(¶ms);
|
||||
|
||||
let mut base_url = url::Url::parse(&self.url)
|
||||
.map_err(|_| PaymentError::Config("invalid EPay URL".into()))?;
|
||||
base_url = base_url
|
||||
.join("/submit.php")
|
||||
.map_err(|_| PaymentError::Config("invalid EPay path".into()))?;
|
||||
|
||||
{
|
||||
let mut pairs = base_url.query_pairs_mut();
|
||||
for (k, v) in ¶ms {
|
||||
pairs.append_pair(k, v);
|
||||
}
|
||||
pairs.append_pair("sign", &sign);
|
||||
pairs.append_pair("sign_type", "MD5");
|
||||
}
|
||||
|
||||
Ok(base_url.to_string())
|
||||
}
|
||||
|
||||
pub fn verify_sign(&self, params: &std::collections::HashMap<String, String>) -> bool {
|
||||
let mut sorted = BTreeMap::new();
|
||||
for (k, v) in params {
|
||||
sorted.insert(k.as_str(), v.clone());
|
||||
}
|
||||
let expected = params.get("sign").cloned().unwrap_or_default();
|
||||
self.create_sign(&sorted) == expected
|
||||
}
|
||||
|
||||
pub async fn query_order_status(&self, order_no: &str) -> Result<bool, PaymentError> {
|
||||
let query_url = format!(
|
||||
"{}/api.php?act=order&pid={}&out_trade_no={}",
|
||||
self.url, self.pid, order_no
|
||||
);
|
||||
let resp = reqwest::get(&query_url).await?;
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let status = body.get("status").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
Ok(status == 1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_epay_sign() {
|
||||
let provider = Provider::new(Config {
|
||||
pid: "1654".into(),
|
||||
url: "http://127.0.0.1".into(),
|
||||
key: "LbTabbB580zWyhXhyyww7wwvy5u8k0wl".into(),
|
||||
pay_type: "alipay".into(),
|
||||
});
|
||||
|
||||
let order = Order {
|
||||
name: "product".into(),
|
||||
order_no: "202412152115078262977262254".into(),
|
||||
amount: Cents(1000),
|
||||
sign_type: "MD5".into(),
|
||||
notify_url: "".into(),
|
||||
return_url: "".into(),
|
||||
};
|
||||
|
||||
let url = provider.create_pay_url(&order).unwrap();
|
||||
assert!(url.contains("sign="));
|
||||
assert!(url.contains("sign_type=MD5"));
|
||||
|
||||
// Verify sign from callback params (matches Go test data)
|
||||
let params = std::collections::HashMap::from([
|
||||
("pid".into(), "1654".into()),
|
||||
("trade_no".into(), "2024121521150860990".into()),
|
||||
("out_trade_no".into(), "202412152115078262977262254".into()),
|
||||
("type".into(), "alipay".into()),
|
||||
("name".into(), "product".into()),
|
||||
("money".into(), "10".into()),
|
||||
("trade_status".into(), "TRADE_SUCCESS".into()),
|
||||
("sign".into(), "d3181f18ebdf9821f0ab6ee93faa82d1".into()),
|
||||
("sign_type".into(), "MD5".into()),
|
||||
]);
|
||||
assert!(provider.verify_sign(¶ms));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PaymentError {
|
||||
#[error("Stripe error: {0}")]
|
||||
Stripe(#[from] stripe::StripeError),
|
||||
|
||||
#[error("Stripe webhook error: {0}")]
|
||||
StripeWebhook(String),
|
||||
|
||||
#[error("Alipay error: {0}")]
|
||||
Alipay(String),
|
||||
|
||||
#[error("EPay error: {0}")]
|
||||
EPay(String),
|
||||
|
||||
#[error("HTTP request failed: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
#[error("URL parse error: {0}")]
|
||||
Url(#[from] url::ParseError),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
#[error("Invalid configuration: {0}")]
|
||||
Config(String),
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod alipay;
|
||||
pub mod epay;
|
||||
pub mod error;
|
||||
pub mod platform;
|
||||
pub mod stripe;
|
||||
pub mod types;
|
||||
|
||||
pub use error::PaymentError;
|
||||
pub use platform::{get_supported_platforms, Platform, PlatformInfo};
|
||||
pub use types::{Cents, Notification, Order, PaymentSheet, User};
|
||||
@@ -0,0 +1,146 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
Stripe,
|
||||
AlipayF2F,
|
||||
EPay,
|
||||
Balance,
|
||||
CryptoSaaS,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
const PLATFORM_NAMES: &[(&str, Platform)] = &[
|
||||
("CryptoSaaS", Platform::CryptoSaaS),
|
||||
("Stripe", Platform::Stripe),
|
||||
("AlipayF2F", Platform::AlipayF2F),
|
||||
("EPay", Platform::EPay),
|
||||
("balance", Platform::Balance),
|
||||
];
|
||||
|
||||
impl Platform {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
for &(name, platform) in PLATFORM_NAMES {
|
||||
if name == s {
|
||||
return platform;
|
||||
}
|
||||
}
|
||||
Platform::Unsupported
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
for &(name, platform) in PLATFORM_NAMES {
|
||||
if platform == *self {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
"unsupported"
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Platform {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Platform {
|
||||
type Err = ();
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match Self::from_str(s) {
|
||||
Platform::Unsupported => Err(()),
|
||||
p => Ok(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PlatformInfo {
|
||||
pub platform: String,
|
||||
pub platform_url: String,
|
||||
pub platform_field_description: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn get_supported_platforms() -> Vec<PlatformInfo> {
|
||||
vec![
|
||||
PlatformInfo {
|
||||
platform: "Stripe".into(),
|
||||
platform_url: "https://stripe.com".into(),
|
||||
platform_field_description: {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
m.insert("public_key".into(), "Publishable key".into());
|
||||
m.insert("secret_key".into(), "Secret key".into());
|
||||
m.insert("webhook_secret".into(), "Webhook secret".into());
|
||||
m.insert("payment".into(), "Payment Method, only supported card/alipay/wechat_pay".into());
|
||||
m
|
||||
},
|
||||
},
|
||||
PlatformInfo {
|
||||
platform: "AlipayF2F".into(),
|
||||
platform_url: "https://alipay.com".into(),
|
||||
platform_field_description: {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
m.insert("app_id".into(), "App ID".into());
|
||||
m.insert("private_key".into(), "Private Key".into());
|
||||
m.insert("public_key".into(), "Public Key".into());
|
||||
m.insert("invoice_name".into(), "Invoice Name".into());
|
||||
m.insert("sandbox".into(), "Sandbox Mode".into());
|
||||
m
|
||||
},
|
||||
},
|
||||
PlatformInfo {
|
||||
platform: "EPay".into(),
|
||||
platform_url: String::new(),
|
||||
platform_field_description: {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
m.insert("pid".into(), "PID".into());
|
||||
m.insert("url".into(), "URL".into());
|
||||
m.insert("key".into(), "Key".into());
|
||||
m.insert("type".into(), "Type".into());
|
||||
m
|
||||
},
|
||||
},
|
||||
PlatformInfo {
|
||||
platform: "CryptoSaaS".into(),
|
||||
platform_url: "https://t.me/CryptoSaaSBot".into(),
|
||||
platform_field_description: {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
m.insert("endpoint".into(), "API Endpoint".into());
|
||||
m.insert("account_id".into(), "Account ID".into());
|
||||
m.insert("secret_key".into(), "Secret Key".into());
|
||||
m
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_platform_parse() {
|
||||
assert_eq!(Platform::from_str("Stripe"), Platform::Stripe);
|
||||
assert_eq!(Platform::from_str("AlipayF2F"), Platform::AlipayF2F);
|
||||
assert_eq!(Platform::from_str("EPay"), Platform::EPay);
|
||||
assert_eq!(Platform::from_str("balance"), Platform::Balance);
|
||||
assert_eq!(Platform::from_str("CryptoSaaS"), Platform::CryptoSaaS);
|
||||
assert_eq!(Platform::from_str("unknown"), Platform::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_display() {
|
||||
assert_eq!(Platform::Stripe.to_string(), "Stripe");
|
||||
assert_eq!(Platform::Unsupported.to_string(), "unsupported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_supported_platforms() {
|
||||
let platforms = get_supported_platforms();
|
||||
assert_eq!(platforms.len(), 4);
|
||||
assert!(platforms.iter().any(|p| p.platform == "Stripe"));
|
||||
assert!(platforms.iter().any(|p| p.platform == "EPay"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use stripe::{
|
||||
Client as StripeClient, CreateCustomer, CreateEphemeralKey, CreatePaymentIntent,
|
||||
CreateWebhookEndpoint, Customer, CustomerSearchParams, EphemeralKey, EventFilter,
|
||||
EventObject, Expandable, PaymentIntent, PaymentIntentId, PaymentIntentStatus,
|
||||
PaymentMethod, PaymentMethodId, Webhook, WebhookEndpoint,
|
||||
};
|
||||
|
||||
use crate::error::PaymentError;
|
||||
use crate::types::{Cents, Notification, Order, PaymentSheet, User};
|
||||
|
||||
pub const API_VERSION: &str = "2024-04-10";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub public_key: String,
|
||||
pub secret_key: String,
|
||||
pub webhook_secret: String,
|
||||
}
|
||||
|
||||
pub struct Provider {
|
||||
client: StripeClient,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
pub fn new(config: Config) -> Self {
|
||||
let client = StripeClient::new(&config.secret_key);
|
||||
Provider { client, config }
|
||||
}
|
||||
|
||||
pub async fn create_payment_sheet(
|
||||
&self,
|
||||
order: &Order,
|
||||
user: &User,
|
||||
) -> Result<PaymentSheet, PaymentError> {
|
||||
let customer = self.find_or_create_customer(user).await?;
|
||||
|
||||
let mut ek_params = CreateEphemeralKey::new();
|
||||
ek_params.customer = Some(customer.id.clone());
|
||||
let ek = EphemeralKey::create(&self.client, ek_params).await?;
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("order_no".to_string(), order.order_no.clone());
|
||||
metadata.insert("user_id".to_string(), user.user_id.to_string());
|
||||
metadata.insert("subscribe".to_string(), order.subscribe.clone());
|
||||
|
||||
let currency = stripe::Currency::from_str(&order.currency)
|
||||
.map_err(|e| PaymentError::Config(format!("invalid currency: {e}")))?;
|
||||
|
||||
let mut pi_params = CreatePaymentIntent::new(order.amount.0, currency);
|
||||
pi_params.customer = Some(customer.id.clone());
|
||||
pi_params.payment_method_types = Some(vec![order.payment.clone()]);
|
||||
pi_params.metadata = Some(metadata);
|
||||
|
||||
let pi = PaymentIntent::create(&self.client, pi_params).await?;
|
||||
|
||||
Ok(PaymentSheet {
|
||||
client_secret: pi.client_secret.unwrap_or_default(),
|
||||
ephemeral_key: ek.secret.unwrap_or_default(),
|
||||
customer: customer.id.to_string(),
|
||||
publishable_key: self.config.public_key.clone(),
|
||||
trade_no: pi.id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn find_or_create_customer(&self, user: &User) -> Result<Customer, PaymentError> {
|
||||
if let Some(customer) = self.search_customer(user).await? {
|
||||
return Ok(customer);
|
||||
}
|
||||
self.create_customer(user).await
|
||||
}
|
||||
|
||||
pub async fn search_customer(&self, user: &User) -> Result<Option<Customer>, PaymentError> {
|
||||
let query = if !user.email.is_empty() {
|
||||
format!("email:'{}'", user.email)
|
||||
} else {
|
||||
format!("metadata['user_id']:'{}'", user.user_id)
|
||||
};
|
||||
|
||||
let mut params = CustomerSearchParams::new();
|
||||
params.query = query;
|
||||
|
||||
let result = Customer::search(&self.client, params).await?;
|
||||
Ok(result.data.into_iter().next())
|
||||
}
|
||||
|
||||
pub async fn create_customer(&self, user: &User) -> Result<Customer, PaymentError> {
|
||||
let mut params = CreateCustomer::new();
|
||||
if !user.email.is_empty() {
|
||||
params.email = Some(&user.email);
|
||||
}
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("user_id".to_string(), user.user_id.to_string());
|
||||
params.metadata = Some(metadata);
|
||||
|
||||
Ok(Customer::create(&self.client, params).await?)
|
||||
}
|
||||
|
||||
pub async fn query_order_status(&self, trade_no: &str) -> Result<bool, PaymentError> {
|
||||
let id = PaymentIntentId::from_str(trade_no)
|
||||
.map_err(|_| PaymentError::Config("invalid PaymentIntent ID".into()))?;
|
||||
let intent = PaymentIntent::retrieve(&self.client, &id, &[]).await?;
|
||||
Ok(intent.status == PaymentIntentStatus::Succeeded)
|
||||
}
|
||||
|
||||
pub fn parse_notify(
|
||||
&self,
|
||||
payload: &[u8],
|
||||
signature: &str,
|
||||
) -> Result<Notification, PaymentError> {
|
||||
let payload_str =
|
||||
std::str::from_utf8(payload).map_err(|e| PaymentError::StripeWebhook(e.to_string()))?;
|
||||
|
||||
let event = Webhook::construct_event(payload_str, signature, &self.config.webhook_secret)
|
||||
.map_err(|e| PaymentError::StripeWebhook(e.to_string()))?;
|
||||
|
||||
let pi = match event.data.object {
|
||||
EventObject::PaymentIntent(pi) => pi,
|
||||
_ => {
|
||||
return Err(PaymentError::StripeWebhook(
|
||||
"unexpected event object type".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let order_no = pi.metadata.get("order_no").cloned().unwrap_or_default();
|
||||
let user_id: i64 = pi
|
||||
.metadata
|
||||
.get("user_id")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let method = match pi.payment_method {
|
||||
Some(Expandable::Object(ref pm)) => Some(pm.type_.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(Notification {
|
||||
event_type: event.type_.to_string(),
|
||||
order_no,
|
||||
trade_no: pi.id.to_string(),
|
||||
user_id,
|
||||
amount: Cents(pi.amount),
|
||||
method,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn retrieve_payment_method(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<PaymentMethod, PaymentError> {
|
||||
let pm_id = PaymentMethodId::from_str(id)
|
||||
.map_err(|_| PaymentError::Config("invalid PaymentMethod ID".into()))?;
|
||||
Ok(PaymentMethod::retrieve(&self.client, &pm_id, &[]).await?)
|
||||
}
|
||||
|
||||
pub async fn create_webhook_endpoint(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> Result<WebhookEndpoint, PaymentError> {
|
||||
let params = CreateWebhookEndpoint::new(
|
||||
vec![EventFilter::PaymentIntentSucceeded, EventFilter::PaymentIntentPaymentFailed],
|
||||
url,
|
||||
);
|
||||
Ok(WebhookEndpoint::create(&self.client, params).await?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::ops::{Div, Mul};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Cents(pub i64);
|
||||
|
||||
impl Cents {
|
||||
pub fn from_yuan(s: &str) -> Result<Self, std::num::ParseFloatError> {
|
||||
let yuan: f64 = s.parse()?;
|
||||
Ok(Cents((yuan * 100.0).round() as i64))
|
||||
}
|
||||
|
||||
pub fn to_yuan_f64(&self) -> f64 {
|
||||
self.0 as f64 / 100.0
|
||||
}
|
||||
|
||||
pub fn to_yuan_string(&self) -> String {
|
||||
format!("{:.2}", self.to_yuan_f64())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Cents {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<i64> for Cents {
|
||||
type Output = Cents;
|
||||
fn mul(self, rhs: i64) -> Cents {
|
||||
Cents(self.0 * rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Div<i64> for Cents {
|
||||
type Output = Cents;
|
||||
fn div(self, rhs: i64) -> Cents {
|
||||
Cents(self.0 / rhs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cents_from_yuan() {
|
||||
let c = Cents::from_yuan("10.00").unwrap();
|
||||
assert_eq!(c.0, 1000);
|
||||
assert_eq!(c.to_yuan_string(), "10.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cents_from_yuan_rounding() {
|
||||
let c = Cents::from_yuan("9.99").unwrap();
|
||||
assert_eq!(c.0, 999);
|
||||
assert_eq!(c.to_yuan_string(), "9.99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cents_display() {
|
||||
let c = Cents(100);
|
||||
assert_eq!(c.to_string(), "100");
|
||||
assert_eq!(c.to_yuan_f64(), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Order {
|
||||
pub order_no: String,
|
||||
pub amount: Cents,
|
||||
pub currency: String,
|
||||
pub payment: String,
|
||||
pub subscribe: String,
|
||||
pub name: String,
|
||||
pub notify_url: String,
|
||||
pub return_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub user_id: i64,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Notification {
|
||||
pub event_type: String,
|
||||
pub order_no: String,
|
||||
pub trade_no: String,
|
||||
pub user_id: i64,
|
||||
pub amount: Cents,
|
||||
pub method: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PaymentSheet {
|
||||
pub client_secret: String,
|
||||
pub ephemeral_key: String,
|
||||
pub customer: String,
|
||||
pub publishable_key: String,
|
||||
pub trade_no: String,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "result"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
axum = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
@@ -0,0 +1,120 @@
|
||||
// Code-carrying error type.
|
||||
//
|
||||
// Ported from the Go package `xerr` (errors.go). A `CodeError` carries a
|
||||
// numeric error code (shown to the front end) and a human-readable message,
|
||||
// and can travel through an error chain so handlers can recover the code via
|
||||
// [`find_code_error`].
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::error_code::{map_err_msg, ERROR};
|
||||
|
||||
/// An error that carries a machine-readable code and a message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodeError {
|
||||
err_code: u32,
|
||||
err_msg: String,
|
||||
}
|
||||
|
||||
impl CodeError {
|
||||
/// Creates a `CodeError` whose message is looked up from [`map_err_msg`].
|
||||
///
|
||||
/// Mirrors Go `xerr.NewErrCode`.
|
||||
pub fn new_err_code(err_code: u32) -> Self {
|
||||
Self {
|
||||
err_code,
|
||||
err_msg: map_err_msg(err_code).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a `CodeError` with an explicit code and message.
|
||||
///
|
||||
/// Mirrors Go `xerr.NewErrCodeMsg`.
|
||||
pub fn new_err_code_msg(err_code: u32, err_msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
err_code,
|
||||
err_msg: err_msg.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a `CodeError` for an unspecified failure (`ERROR` code).
|
||||
///
|
||||
/// Mirrors Go `xerr.NewErrMsg`.
|
||||
pub fn new_err_msg(err_msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
err_code: ERROR,
|
||||
err_msg: err_msg.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the error code shown to the front end.
|
||||
pub fn get_err_code(&self) -> u32 {
|
||||
self.err_code
|
||||
}
|
||||
|
||||
/// Returns the error message shown to the front end.
|
||||
pub fn get_err_msg(&self) -> &str {
|
||||
&self.err_msg
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CodeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Preserve the original Go format (note the full-width comma).
|
||||
write!(f, "ErrCode:{},ErrMsg:{}", self.err_code, self.err_msg)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CodeError {}
|
||||
|
||||
/// Sentinel error for "304 Not Modified".
|
||||
///
|
||||
/// Mirrors Go `xerr.StatusNotModified`.
|
||||
pub static STATUS_NOT_MODIFIED: LazyLock<SimpleError> =
|
||||
LazyLock::new(|| SimpleError("304 Not Modified".to_string()));
|
||||
|
||||
/// A plain string-backed error, used for sentinel values such as
|
||||
/// [`STATUS_NOT_MODIFIED`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimpleError(pub String);
|
||||
|
||||
impl fmt::Display for SimpleError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SimpleError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_err_code_maps_message() {
|
||||
let e = CodeError::new_err_code(crate::error_code::INVALID_PARAMS);
|
||||
assert_eq!(e.get_err_code(), 400);
|
||||
assert_eq!(e.get_err_msg(), "Param Error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_err_code_msg_is_explicit() {
|
||||
let e = CodeError::new_err_code_msg(123, "custom");
|
||||
assert_eq!(e.get_err_code(), 123);
|
||||
assert_eq!(e.get_err_msg(), "custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_err_msg_uses_error_code() {
|
||||
let e = CodeError::new_err_msg("boom");
|
||||
assert_eq!(e.get_err_code(), ERROR);
|
||||
assert_eq!(e.get_err_msg(), "boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_preserves_go_format() {
|
||||
let e = CodeError::new_err_code_msg(400, "Param Error");
|
||||
assert_eq!(e.to_string(), "ErrCode:400,ErrMsg:Param Error");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Error codes and their human-readable messages.
|
||||
//
|
||||
// Ported from the Go package `xerr` (errCode.go + errMsg.go). The first three
|
||||
// digits identify the business area; the last three identify the specific
|
||||
// error within that area.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// General error codes.
|
||||
pub const SUCCESS: u32 = 200;
|
||||
pub const ERROR: u32 = 500;
|
||||
|
||||
/// Database errors.
|
||||
pub const DATABASE_QUERY_ERROR: u32 = 10001;
|
||||
pub const DATABASE_UPDATE_ERROR: u32 = 10002;
|
||||
pub const DATABASE_INSERT_ERROR: u32 = 10003;
|
||||
pub const DATABASE_DELETED_ERROR: u32 = 10004;
|
||||
|
||||
/// User errors.
|
||||
pub const USER_EXIST: u32 = 20001;
|
||||
pub const USER_NOT_EXIST: u32 = 20002;
|
||||
pub const USER_PASSWORD_ERROR: u32 = 20003;
|
||||
pub const USER_DISABLED: u32 = 20004;
|
||||
pub const INSUFFICIENT_BALANCE: u32 = 20005;
|
||||
pub const STOP_REGISTER: u32 = 20006;
|
||||
pub const TELEGRAM_NOT_BOUND: u32 = 20007;
|
||||
pub const USER_NOT_BIND_OAUTH: u32 = 20008;
|
||||
pub const INVITE_CODE_ERROR: u32 = 20009;
|
||||
pub const USER_COMMISSION_NOT_ENOUGH: u32 = 20010;
|
||||
|
||||
/// Node errors.
|
||||
pub const NODE_EXIST: u32 = 30001;
|
||||
pub const NODE_NOT_EXIST: u32 = 30002;
|
||||
pub const NODE_GROUP_EXIST: u32 = 30003;
|
||||
pub const NODE_GROUP_NOT_EXIST: u32 = 30004;
|
||||
pub const NODE_GROUP_NOT_EMPTY: u32 = 30005;
|
||||
|
||||
/// Request errors.
|
||||
pub const INVALID_PARAMS: u32 = 400;
|
||||
pub const TOO_MANY_REQUESTS: u32 = 401;
|
||||
pub const ERROR_TOKEN_EMPTY: u32 = 40002;
|
||||
pub const ERROR_TOKEN_INVALID: u32 = 40003;
|
||||
pub const ERROR_TOKEN_EXPIRE: u32 = 40004;
|
||||
pub const INVALID_ACCESS: u32 = 40005;
|
||||
pub const INVALID_CIPHERTEXT: u32 = 40006;
|
||||
pub const SECRET_IS_EMPTY: u32 = 40007;
|
||||
|
||||
/// Coupon errors.
|
||||
pub const COUPON_NOT_EXIST: u32 = 50001;
|
||||
pub const COUPON_ALREADY_USED: u32 = 50002;
|
||||
pub const COUPON_NOT_APPLICABLE: u32 = 50003;
|
||||
pub const COUPON_INSUFFICIENT_USAGE: u32 = 50004;
|
||||
pub const COUPON_EXPIRED: u32 = 50005;
|
||||
pub const COUPON_DISABLED: u32 = 50006;
|
||||
|
||||
/// Subscribe errors.
|
||||
pub const SUBSCRIBE_EXPIRED: u32 = 60001;
|
||||
pub const SUBSCRIBE_NOT_AVAILABLE: u32 = 60002;
|
||||
pub const USER_SUBSCRIBE_EXIST: u32 = 60003;
|
||||
pub const SUBSCRIBE_IS_USED_ERROR: u32 = 60004;
|
||||
pub const SINGLE_SUBSCRIBE_MODE_EXCEEDS_LIMIT: u32 = 60005;
|
||||
pub const SUBSCRIBE_QUOTA_LIMIT: u32 = 60006;
|
||||
pub const SUBSCRIBE_OUT_OF_STOCK: u32 = 60007;
|
||||
|
||||
/// Order errors.
|
||||
pub const ORDER_NOT_EXIST: u32 = 61001;
|
||||
pub const PAYMENT_METHOD_NOT_FOUND: u32 = 61002;
|
||||
pub const ORDER_STATUS_ERROR: u32 = 61003;
|
||||
pub const INSUFFICIENT_OF_PERIOD: u32 = 61004;
|
||||
pub const EXIST_AVAILABLE_TRAFFIC: u32 = 61005;
|
||||
|
||||
/// Auth errors.
|
||||
pub const VERIFY_CODE_ERROR: u32 = 70001;
|
||||
|
||||
/// Equipment errors.
|
||||
pub const QUEUE_ENQUEUE_ERROR: u32 = 80001;
|
||||
|
||||
/// System errors.
|
||||
pub const DEBUG_MODE_ERROR: u32 = 90001;
|
||||
pub const SEND_SMS_ERROR: u32 = 90002;
|
||||
pub const SMS_NOT_ENABLED: u32 = 90003;
|
||||
pub const EMAIL_NOT_ENABLED: u32 = 90004;
|
||||
pub const GET_AUTHENTICATOR_ERROR: u32 = 90005;
|
||||
pub const AUTHENTICATOR_NOT_SUPPORTED_ERROR: u32 = 90006;
|
||||
pub const TELEPHONE_AREA_CODE_IS_EMPTY: u32 = 90007;
|
||||
pub const TODAY_SEND_COUNT_EXCEEDS_LIMIT: u32 = 90015;
|
||||
pub const PASSWORD_IS_EMPTY: u32 = 90008;
|
||||
pub const AREA_CODE_IS_EMPTY: u32 = 90009;
|
||||
pub const PASSWORD_OR_VERIFICATION_CODE_REQUIRED: u32 = 90010;
|
||||
pub const EMAIL_EXIST: u32 = 90011;
|
||||
pub const TELEPHONE_EXIST: u32 = 90012;
|
||||
pub const DEVICE_EXIST: u32 = 90013;
|
||||
pub const TELEPHONE_ERROR: u32 = 90014;
|
||||
pub const DEVICE_NOT_EXIST: u32 = 90017;
|
||||
pub const USERID_NOT_MATCH: u32 = 90018;
|
||||
|
||||
/// Mapping of error code -> default message.
|
||||
static MESSAGES: LazyLock<HashMap<u32, &'static str>> = LazyLock::new(|| {
|
||||
let mut m = HashMap::new();
|
||||
// General
|
||||
m.insert(SUCCESS, "Success");
|
||||
m.insert(ERROR, "Internal Server Error");
|
||||
// Request / parameter
|
||||
m.insert(TOO_MANY_REQUESTS, "Too Many Requests");
|
||||
m.insert(INVALID_PARAMS, "Param Error");
|
||||
m.insert(ERROR_TOKEN_EMPTY, "User token is empty");
|
||||
m.insert(ERROR_TOKEN_INVALID, "User token is invalid");
|
||||
m.insert(ERROR_TOKEN_EXPIRE, "User token is expired");
|
||||
m.insert(SECRET_IS_EMPTY, "Secret is empty");
|
||||
m.insert(INVALID_ACCESS, "Invalid access");
|
||||
m.insert(INVALID_CIPHERTEXT, "Invalid ciphertext");
|
||||
// Database
|
||||
m.insert(DATABASE_QUERY_ERROR, "Database query error");
|
||||
m.insert(DATABASE_UPDATE_ERROR, "Database update error");
|
||||
m.insert(DATABASE_INSERT_ERROR, "Database insert error");
|
||||
m.insert(DATABASE_DELETED_ERROR, "Database deleted error");
|
||||
// User
|
||||
m.insert(USER_EXIST, "User already exists");
|
||||
m.insert(USER_NOT_EXIST, "User does not exist");
|
||||
m.insert(USER_PASSWORD_ERROR, "User password error");
|
||||
m.insert(USER_DISABLED, "User disabled");
|
||||
m.insert(INSUFFICIENT_BALANCE, "Insufficient balance");
|
||||
m.insert(STOP_REGISTER, "Stop register");
|
||||
m.insert(TELEGRAM_NOT_BOUND, "Telegram not bound ");
|
||||
m.insert(USER_NOT_BIND_OAUTH, "User not bind oauth method");
|
||||
m.insert(INVITE_CODE_ERROR, "Invite code error");
|
||||
// Node
|
||||
m.insert(NODE_EXIST, "Node already exists");
|
||||
m.insert(NODE_NOT_EXIST, "Node does not exist");
|
||||
m.insert(NODE_GROUP_EXIST, "Node group already exists");
|
||||
m.insert(NODE_GROUP_NOT_EXIST, "Node group does not exist");
|
||||
m.insert(NODE_GROUP_NOT_EMPTY, "Node group is not empty");
|
||||
// Coupon
|
||||
m.insert(COUPON_NOT_EXIST, "Coupon does not exist");
|
||||
m.insert(COUPON_ALREADY_USED, "Coupon has already been used");
|
||||
m.insert(COUPON_NOT_APPLICABLE, "Coupon does not match the order or conditions");
|
||||
m.insert(COUPON_INSUFFICIENT_USAGE, "Coupon has insufficient remaining uses");
|
||||
m.insert(COUPON_EXPIRED, "Coupon is expired");
|
||||
m.insert(COUPON_DISABLED, "Coupon is disabled");
|
||||
// Subscribe
|
||||
m.insert(SUBSCRIBE_EXPIRED, "Subscribe is expired");
|
||||
m.insert(SUBSCRIBE_NOT_AVAILABLE, "Subscribe is not available");
|
||||
m.insert(USER_SUBSCRIBE_EXIST, "User has subscription");
|
||||
m.insert(SUBSCRIBE_IS_USED_ERROR, "Subscribe is used");
|
||||
m.insert(
|
||||
SINGLE_SUBSCRIBE_MODE_EXCEEDS_LIMIT,
|
||||
"Single subscribe mode exceeds limit",
|
||||
);
|
||||
m.insert(SUBSCRIBE_QUOTA_LIMIT, "Subscribe quota limit");
|
||||
m.insert(SUBSCRIBE_OUT_OF_STOCK, "Subscribe out of stock");
|
||||
// Auth
|
||||
m.insert(VERIFY_CODE_ERROR, "Verify code error");
|
||||
// Equipment
|
||||
m.insert(QUEUE_ENQUEUE_ERROR, " Queue enqueue error");
|
||||
// System
|
||||
m.insert(DEBUG_MODE_ERROR, "Debug mode is enabled");
|
||||
m.insert(GET_AUTHENTICATOR_ERROR, "Unsupported login method");
|
||||
m.insert(
|
||||
AUTHENTICATOR_NOT_SUPPORTED_ERROR,
|
||||
"The authenticator does not support this method",
|
||||
);
|
||||
m.insert(TELEPHONE_AREA_CODE_IS_EMPTY, "Telephone area code is empty");
|
||||
m.insert(
|
||||
TODAY_SEND_COUNT_EXCEEDS_LIMIT,
|
||||
"This account has reached the limit of sending times today",
|
||||
);
|
||||
m.insert(SMS_NOT_ENABLED, "Telephone login is not enabled");
|
||||
m.insert(EMAIL_NOT_ENABLED, "Email function is not enabled yet");
|
||||
m.insert(
|
||||
PASSWORD_OR_VERIFICATION_CODE_REQUIRED,
|
||||
"Password or verification code required",
|
||||
);
|
||||
m.insert(EMAIL_EXIST, "Email already exists");
|
||||
m.insert(TELEPHONE_EXIST, "Telephone already exists");
|
||||
m.insert(DEVICE_EXIST, "device exists");
|
||||
m.insert(PASSWORD_IS_EMPTY, "password is empty");
|
||||
m.insert(TELEPHONE_ERROR, "telephone number error");
|
||||
m.insert(DEVICE_NOT_EXIST, "Device does not exist");
|
||||
m.insert(USERID_NOT_MATCH, "Userid not match");
|
||||
// Order
|
||||
m.insert(ORDER_NOT_EXIST, "Order does not exist");
|
||||
m.insert(PAYMENT_METHOD_NOT_FOUND, "Payment method not found");
|
||||
m.insert(ORDER_STATUS_ERROR, "Order status error");
|
||||
m.insert(INSUFFICIENT_OF_PERIOD, "Insufficient number of period");
|
||||
m
|
||||
});
|
||||
|
||||
/// Returns the default message for an error code.
|
||||
///
|
||||
/// Mirrors Go `xerr.MapErrMsg`: falls back to `"Internal Server Error"` when the
|
||||
/// code is unknown.
|
||||
pub fn map_err_msg(err_code: u32) -> &'static str {
|
||||
MESSAGES
|
||||
.get(&err_code)
|
||||
.copied()
|
||||
.unwrap_or("Internal Server Error")
|
||||
}
|
||||
|
||||
/// Returns `true` when the code is a known, registered error code.
|
||||
///
|
||||
/// Mirrors Go `xerr.IsCodeErr`.
|
||||
pub fn is_code_err(err_code: u32) -> bool {
|
||||
MESSAGES.contains_key(&err_code)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_known_codes() {
|
||||
assert_eq!(map_err_msg(SUCCESS), "Success");
|
||||
assert_eq!(map_err_msg(ERROR), "Internal Server Error");
|
||||
assert_eq!(map_err_msg(INVALID_PARAMS), "Param Error");
|
||||
assert_eq!(map_err_msg(ORDER_NOT_EXIST), "Order does not exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_code_falls_back() {
|
||||
assert_eq!(map_err_msg(999_999), "Internal Server Error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_code_err_recognizes_registered_codes() {
|
||||
assert!(is_code_err(SUCCESS));
|
||||
assert!(is_code_err(INVALID_PARAMS));
|
||||
assert!(!is_code_err(999_999));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Response envelopes and HTTP result construction.
|
||||
//
|
||||
// Ported from the Go package `result` (responseBean.go + httpResult.go). The Go
|
||||
// package wraps responses in `ResponseSuccessBean` / `ResponseErrorBean` and
|
||||
// writes them through a Hertz `*Context`; in axum the equivalent is producing a
|
||||
// type that implements `IntoResponse`, so those beans are exposed directly.
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use std::error::Error as StdError;
|
||||
|
||||
use crate::code_error::CodeError;
|
||||
use crate::error_code;
|
||||
|
||||
/// Envelope returned for a successful request.
|
||||
///
|
||||
/// Mirrors Go `result.ResponseSuccessBean`. `data` is omitted from the JSON
|
||||
/// payload when `None` (the `omitempty`-style `skip_serializing_if`).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResponseSuccessBean<T = serde_json::Value> {
|
||||
pub code: u32,
|
||||
pub msg: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<T>,
|
||||
}
|
||||
|
||||
impl<T> ResponseSuccessBean<T> {
|
||||
pub fn new(data: Option<T>) -> Self {
|
||||
Self {
|
||||
code: 200,
|
||||
msg: "success".to_string(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker for an empty (null) data payload, kept for parity with the Go
|
||||
/// `NullJson` type.
|
||||
pub struct NullJson;
|
||||
|
||||
/// Envelope returned for a failed request.
|
||||
///
|
||||
/// Mirrors Go `result.ResponseErrorBean`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResponseErrorBean {
|
||||
pub code: u32,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
/// Builds a success envelope wrapping `data`.
|
||||
///
|
||||
/// Mirrors Go `result.Success`.
|
||||
pub fn success<T>(data: T) -> ResponseSuccessBean<T> {
|
||||
ResponseSuccessBean::new(Some(data))
|
||||
}
|
||||
|
||||
/// Builds an error envelope for the given code and message.
|
||||
///
|
||||
/// Mirrors Go `result.Error`.
|
||||
pub fn error(err_code: u32, err_msg: impl Into<String>) -> ResponseErrorBean {
|
||||
ResponseErrorBean {
|
||||
code: err_code,
|
||||
msg: err_msg.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A structured HTTP response, identical in intent to the Go `HTTPResult`:
|
||||
/// an HTTP status code paired with the serialized body. Axum's `Response`
|
||||
/// subsumes the original `(StatusCode, body)` pair.
|
||||
#[derive(Debug)]
|
||||
pub struct HttpResult {
|
||||
pub status_code: StatusCode,
|
||||
pub body: Response,
|
||||
}
|
||||
|
||||
/// Constructs an `HttpResult` from a fallible response, normalizing the body
|
||||
/// into a success or error envelope.
|
||||
///
|
||||
/// Mirrors Go `result.BuildHTTPResult`:
|
||||
/// - On success: HTTP 200 with a `ResponseSuccessBean` body.
|
||||
/// - On error: HTTP 200 with a `ResponseErrorBean` body, the code/message
|
||||
/// recovered from any nested `CodeError`, defaulting to `ERROR` /
|
||||
/// `"Internal Server Error"` for plain errors.
|
||||
pub fn build_http_result<T>(resp: Option<T>, err: Option<anyhow::Error>) -> HttpResult
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
if let Some(err) = err {
|
||||
let (code, msg) = recover_code_and_msg(&err);
|
||||
return HttpResult {
|
||||
status_code: StatusCode::OK,
|
||||
body: error(code, msg).into_response_body(),
|
||||
};
|
||||
}
|
||||
|
||||
HttpResult {
|
||||
status_code: StatusCode::OK,
|
||||
body: Json(ResponseSuccessBean::new(resp)).into_response_body(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs an `HttpResult` for a parameter-validation failure.
|
||||
///
|
||||
/// Mirrors Go `result.BuildParamErrorResult`: HTTP 200 with a `ResponseErrorBean`
|
||||
/// whose code is `INVALID_PARAMS` and whose message is the raw error text.
|
||||
pub fn build_param_error_result(err: &dyn StdError) -> HttpResult {
|
||||
HttpResult {
|
||||
status_code: StatusCode::OK,
|
||||
body: error(error_code::INVALID_PARAMS, err.to_string()).into_response_body(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits an HTTP response built from a fallible result.
|
||||
///
|
||||
/// Mirrors Go `result.HttpResult(ctx, resp, err)` (which wrote the body via the
|
||||
/// Hertz context). Here it simply renders the constructed `HttpResult`.
|
||||
pub fn http_result<T>(resp: Option<T>, err: Option<anyhow::Error>) -> Response
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
build_http_result(resp, err).into_response()
|
||||
}
|
||||
|
||||
/// Emits an HTTP response for a parameter-validation failure.
|
||||
///
|
||||
/// Mirrors Go `result.ParamErrorResult(ctx, err)`. The Go version also logged the
|
||||
/// error onto the Hertz error chain (`ctx.Error`); in axum that side effect is
|
||||
/// the caller's responsibility (e.g. a tracing call), so only the response is
|
||||
/// produced here.
|
||||
pub fn param_error_result(err: &dyn StdError) -> Response {
|
||||
build_param_error_result(err).into_response()
|
||||
}
|
||||
|
||||
impl IntoResponse for HttpResult {
|
||||
fn into_response(self) -> Response {
|
||||
self.body
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
|
||||
/// Re-implementation of Go's `errors.As(errors.Cause(err), &e)` chain walk: the
|
||||
/// first `CodeError` found while unwrapping `anyhow::Error` wins.
|
||||
fn recover_code_and_msg(err: &anyhow::Error) -> (u32, String) {
|
||||
for cause in err.chain() {
|
||||
if let Some(code_err) = cause.downcast_ref::<CodeError>() {
|
||||
return (code_err.get_err_code(), code_err.get_err_msg().to_string());
|
||||
}
|
||||
}
|
||||
(error_code::ERROR, "Internal Server Error".to_string())
|
||||
}
|
||||
|
||||
/// Extension so the `(StatusCode, Json<T>)` rendering can be captured as the
|
||||
/// inner `Response` body of an `HttpResult`.
|
||||
trait IntoResponseBody {
|
||||
fn into_response_body(self) -> Response;
|
||||
}
|
||||
|
||||
impl<T: Serialize> IntoResponseBody for Json<T> {
|
||||
fn into_response_body(self) -> Response {
|
||||
(StatusCode::OK, self).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponseBody for ResponseErrorBean {
|
||||
fn into_response_body(self) -> Response {
|
||||
(StatusCode::OK, Json(self)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::to_bytes;
|
||||
use crate::code_error::CodeError;
|
||||
use crate::error_code;
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_http_result_success() {
|
||||
let result = build_http_result(Some("ok"), None);
|
||||
assert_eq!(result.status_code, StatusCode::OK);
|
||||
let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["code"], 200);
|
||||
assert_eq!(v["msg"], "success");
|
||||
assert_eq!(v["data"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_http_result_code_error() {
|
||||
let err = anyhow::Error::new(CodeError::new_err_code(error_code::INVALID_PARAMS));
|
||||
let result = build_http_result::<()>(None, Some(err));
|
||||
let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["code"], 400);
|
||||
assert_eq!(v["msg"], "Param Error");
|
||||
assert!(v.get("data").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_http_result_generic_error() {
|
||||
let err = anyhow::Error::msg("boom");
|
||||
let result = build_http_result::<()>(None, Some(err));
|
||||
let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["code"], 500);
|
||||
assert_eq!(v["msg"], "Internal Server Error");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_param_error_result_works() {
|
||||
let err = anyhow::Error::msg("bad param");
|
||||
let result = build_param_error_result(err.as_ref());
|
||||
let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["code"], 400);
|
||||
assert_eq!(v["msg"], "bad param");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_envelope_shape() {
|
||||
let bean = success(serde_json::json!({"x": 1}));
|
||||
assert_eq!(bean.code, 200);
|
||||
assert_eq!(bean.msg, "success");
|
||||
assert_eq!(bean.data.as_ref().unwrap()["x"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_envelope_shape() {
|
||||
let bean = error(401, "Too Many Requests");
|
||||
assert_eq!(bean.code, 401);
|
||||
assert_eq!(bean.msg, "Too Many Requests");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Response envelopes and HTTP result construction.
|
||||
//
|
||||
// Ported from the Go package `result` together with its `xerr` dependency:
|
||||
// error codes live in [`error_code`], the code-carrying error in
|
||||
// [`code_error`], and the response beans / `HttpResult` in [`http_result`].
|
||||
|
||||
pub mod code_error;
|
||||
pub mod error_code;
|
||||
pub mod http_result;
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "sms"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
hmac = "0.12"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
md5 = "0.7"
|
||||
chrono = "0.4"
|
||||
@@ -0,0 +1,38 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Unified config struct covering all 4 providers.
|
||||
/// Fields not used by a given provider are simply ignored.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SmsConfig {
|
||||
// ── AlibabaCloud ────────────────────────────────────────────────
|
||||
/// AccessKeyId
|
||||
#[serde(default)]
|
||||
pub access: String,
|
||||
/// AccessKeySecret / MD5Key / AuthToken / Password
|
||||
#[serde(default)]
|
||||
pub secret: String,
|
||||
/// AlibabaCloud: SignName
|
||||
#[serde(default)]
|
||||
pub sign_name: String,
|
||||
/// AlibabaCloud: Endpoint (default: dysmsapi.ap-southeast-1.aliyuncs.com)
|
||||
#[serde(default)]
|
||||
pub endpoint: String,
|
||||
/// AlibabaCloud: TemplateCode
|
||||
#[serde(default)]
|
||||
pub template_code: String,
|
||||
|
||||
// ── Smsbao / Abosend / Twilio ────────────────────────────────────
|
||||
/// Go template string, e.g. "Your code is {{.code}}"
|
||||
#[serde(default)]
|
||||
pub template: String,
|
||||
|
||||
// ── Abosend ─────────────────────────────────────────────────────
|
||||
/// Override API base URL (default: https://smsapi.abosend.com)
|
||||
#[serde(default)]
|
||||
pub api_domain: String,
|
||||
|
||||
// ── Twilio ───────────────────────────────────────────────────────
|
||||
/// Sending phone number (e.g. "+12015551234")
|
||||
#[serde(default)]
|
||||
pub phone_number: String,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use crate::config::SmsConfig;
|
||||
use crate::platform::Platform;
|
||||
use crate::providers::{abosend::AbosendSender, alibabacloud::AlibabaCloudSender, smsbao::SmsbaoSender, twilio::TwilioSender};
|
||||
use crate::sender::Sender;
|
||||
|
||||
pub fn create_sender(platform: Platform, config: SmsConfig) -> Box<dyn Sender> {
|
||||
match platform {
|
||||
Platform::AlibabaCloud => Box::new(AlibabaCloudSender::new(config)),
|
||||
Platform::Smsbao => Box::new(SmsbaoSender::new(config)),
|
||||
Platform::Abosend => Box::new(AbosendSender::new(config)),
|
||||
Platform::Twilio => Box::new(TwilioSender::new(config)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod config;
|
||||
pub mod factory;
|
||||
pub mod platform;
|
||||
pub mod providers;
|
||||
pub mod sender;
|
||||
|
||||
pub use config::SmsConfig;
|
||||
pub use factory::create_sender;
|
||||
pub use platform::Platform;
|
||||
pub use sender::Sender;
|
||||
@@ -0,0 +1,33 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Platform {
|
||||
AlibabaCloud,
|
||||
Smsbao,
|
||||
Abosend,
|
||||
Twilio,
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"AlibabaCloud" => Some(Platform::AlibabaCloud),
|
||||
"smsbao" => Some(Platform::Smsbao),
|
||||
"abosend" => Some(Platform::Abosend),
|
||||
"twilio" => Some(Platform::Twilio),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Platform {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Platform::AlibabaCloud => "AlibabaCloud",
|
||||
Platform::Smsbao => "smsbao",
|
||||
Platform::Abosend => "abosend",
|
||||
Platform::Twilio => "twilio",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use anyhow::Context;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::SmsConfig;
|
||||
use crate::sender::Sender;
|
||||
|
||||
const BASE_URL: &str = "https://smsapi.abosend.com";
|
||||
|
||||
pub struct AbosendSender {
|
||||
config: SmsConfig,
|
||||
client: Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl AbosendSender {
|
||||
pub fn new(config: SmsConfig) -> Self {
|
||||
let base_url = if config.api_domain.is_empty() {
|
||||
BASE_URL.to_string()
|
||||
} else {
|
||||
config.api_domain.clone()
|
||||
};
|
||||
Self {
|
||||
config,
|
||||
client: Client::new(),
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AbosendResponse {
|
||||
code: i32,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Render a Go-style template string: replace `{{.code}}` with `code`.
|
||||
fn render_template(template: &str, code: &str) -> String {
|
||||
template.replace("{{.code}}", code)
|
||||
}
|
||||
|
||||
/// Compute MD5 hex string (lowercase).
|
||||
fn md5_hex(input: &str) -> String {
|
||||
let digest = md5::compute(input.as_bytes());
|
||||
format!("{:x}", digest)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Sender for AbosendSender {
|
||||
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
|
||||
let content = render_template(&self.config.template, code);
|
||||
|
||||
// rand is a 6-digit numeric string; we derive it from current time nanos
|
||||
let rand_num = format!(
|
||||
"{:06}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos()
|
||||
% 1_000_000
|
||||
);
|
||||
|
||||
// sign = md5(orgCode + content + rand + md5key), uppercase
|
||||
let sign_input = format!(
|
||||
"{}{}{}{}",
|
||||
self.config.access, content, rand_num, self.config.secret
|
||||
);
|
||||
let sign = md5_hex(&sign_input).to_uppercase();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"orgCode": self.config.access,
|
||||
"mobileArea": format!("+{}", area),
|
||||
"mobiles": format!("{}{}", area, phone),
|
||||
"content": content,
|
||||
"rand": rand_num,
|
||||
"sign": sign,
|
||||
});
|
||||
|
||||
let url = format!("{}/v2/api/sendSMS", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Abosend: HTTP request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Abosend: HTTP {}", status);
|
||||
}
|
||||
|
||||
let result: AbosendResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Abosend: failed to parse response")?;
|
||||
|
||||
if result.code != 200 {
|
||||
anyhow::bail!(
|
||||
"Abosend: send failed, code={}, message={}",
|
||||
result.code,
|
||||
result.message
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use anyhow::Context;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::config::SmsConfig;
|
||||
use crate::sender::Sender;
|
||||
|
||||
const DEFAULT_ENDPOINT: &str = "dysmsapi.ap-southeast-1.aliyuncs.com";
|
||||
|
||||
pub struct AlibabaCloudSender {
|
||||
config: SmsConfig,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl AlibabaCloudSender {
|
||||
pub fn new(config: SmsConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal response body from Dysmsapi SendSms
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SendSmsResponse {
|
||||
#[serde(rename = "Code")]
|
||||
code: String,
|
||||
#[serde(rename = "Message")]
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Build the canonical query string and HMAC-SHA1 signature required by
|
||||
/// Alibaba Cloud's Dysmsapi (RPC-style, API version 2017-05-25).
|
||||
///
|
||||
/// Reference:
|
||||
/// https://help.aliyun.com/document_detail/101341.html
|
||||
fn sign_request(
|
||||
access_key_id: &str,
|
||||
access_key_secret: &str,
|
||||
params: &mut Vec<(String, String)>,
|
||||
) -> anyhow::Result<String> {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha1::Sha1;
|
||||
#[allow(unused_imports)]
|
||||
use sha2::Sha256;
|
||||
|
||||
// Common system parameters
|
||||
let nonce = uuid_v4_simple();
|
||||
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
|
||||
params.push(("AccessKeyId".to_string(), access_key_id.to_string()));
|
||||
params.push(("Format".to_string(), "JSON".to_string()));
|
||||
params.push(("SignatureMethod".to_string(), "HMAC-SHA1".to_string()));
|
||||
params.push(("SignatureNonce".to_string(), nonce));
|
||||
params.push(("SignatureVersion".to_string(), "1.0".to_string()));
|
||||
params.push(("Timestamp".to_string(), timestamp));
|
||||
params.push(("Version".to_string(), "2017-05-25".to_string()));
|
||||
|
||||
// Sort by key
|
||||
params.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
// Percent-encode each key=value pair then join with &
|
||||
let canonical = params
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
format!(
|
||||
"{}={}",
|
||||
percent_encode(k),
|
||||
percent_encode(v)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
let string_to_sign = format!(
|
||||
"GET&{}&{}",
|
||||
percent_encode("/"),
|
||||
percent_encode(&canonical)
|
||||
);
|
||||
|
||||
// Sign with HMAC-SHA1 using "<secret>&" as key
|
||||
let signing_key = format!("{}&", access_key_secret);
|
||||
let mut mac = Hmac::<Sha1>::new_from_slice(signing_key.as_bytes())
|
||||
.context("HMAC-SHA1 init failed")?;
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
let signature = STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
params.push(("Signature".to_string(), signature.clone()));
|
||||
|
||||
// Rebuild final query
|
||||
let query = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
fn percent_encode(s: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
|
||||
| b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
encoded.push_str(&format!("%{:02X}", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn uuid_v4_simple() -> String {
|
||||
// Generate a UUID-like random string without external dep
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let t = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos();
|
||||
format!("{:08x}-{:04x}-4{:03x}", t, t >> 16, t & 0xfff)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Sender for AlibabaCloudSender {
|
||||
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
|
||||
let endpoint = if self.config.endpoint.is_empty() {
|
||||
DEFAULT_ENDPOINT
|
||||
} else {
|
||||
&self.config.endpoint
|
||||
};
|
||||
|
||||
let template_param = json!({ "code": code }).to_string();
|
||||
let phone_number = format!("{}{}", area, phone);
|
||||
|
||||
let mut params: Vec<(String, String)> = vec![
|
||||
("Action".to_string(), "SendSms".to_string()),
|
||||
("PhoneNumbers".to_string(), phone_number),
|
||||
("SignName".to_string(), self.config.sign_name.clone()),
|
||||
("TemplateCode".to_string(), self.config.template_code.clone()),
|
||||
("TemplateParam".to_string(), template_param),
|
||||
];
|
||||
|
||||
let query = sign_request(&self.config.access, &self.config.secret, &mut params)?;
|
||||
let url = format!("https://{}/{}", endpoint, query);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("AlibabaCloud: HTTP request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.context("AlibabaCloud: failed to read body")?;
|
||||
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("AlibabaCloud: HTTP {} — {}", status, body);
|
||||
}
|
||||
|
||||
let result: SendSmsResponse =
|
||||
serde_json::from_str(&body).context("AlibabaCloud: failed to parse response")?;
|
||||
|
||||
if result.code != "OK" {
|
||||
anyhow::bail!(
|
||||
"AlibabaCloud: SendSms failed, code={}, message={}",
|
||||
result.code,
|
||||
result.message
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod alibabacloud;
|
||||
pub mod abosend;
|
||||
pub mod smsbao;
|
||||
pub mod twilio;
|
||||
@@ -0,0 +1,86 @@
|
||||
use anyhow::Context;
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::config::SmsConfig;
|
||||
use crate::sender::Sender;
|
||||
|
||||
const BASE_URL: &str = "https://api.smsbao.com";
|
||||
|
||||
pub struct SmsbaoSender {
|
||||
config: SmsConfig,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl SmsbaoSender {
|
||||
pub fn new(config: SmsConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a Go-style template: replace `{{.code}}` with `code`.
|
||||
fn render_template(template: &str, code: &str) -> String {
|
||||
template.replace("{{.code}}", code)
|
||||
}
|
||||
|
||||
/// MD5 hex (lowercase).
|
||||
fn md5_hex(input: &str) -> String {
|
||||
let digest = md5::compute(input.as_bytes());
|
||||
format!("{:x}", digest)
|
||||
}
|
||||
|
||||
/// Map SMSBao numeric response body to an error description.
|
||||
fn parse_smsbao_error(body: &str) -> anyhow::Result<()> {
|
||||
match body.trim() {
|
||||
"0" => Ok(()),
|
||||
"30" => anyhow::bail!("SMSBao: password error"),
|
||||
"40" => anyhow::bail!("SMSBao: account not found"),
|
||||
"41" => anyhow::bail!("SMSBao: insufficient balance"),
|
||||
"43" => anyhow::bail!("SMSBao: IP address restrictions"),
|
||||
"50" => anyhow::bail!("SMSBao: content contains sensitive words"),
|
||||
"51" => anyhow::bail!("SMSBao: mobile number is incorrect"),
|
||||
other => anyhow::bail!("SMSBao: unknown error code: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Sender for SmsbaoSender {
|
||||
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
|
||||
let content = render_template(&self.config.template, code);
|
||||
let password_md5 = md5_hex(&self.config.secret);
|
||||
|
||||
// Domestic (China, area == "86") → /sms, just mobile number
|
||||
// International → /wsms, prepend +area to number
|
||||
let (api_path, mobile) = if area == "86" {
|
||||
("/sms", phone.to_string())
|
||||
} else {
|
||||
("/wsms", format!("+{}{}", area, phone))
|
||||
};
|
||||
|
||||
let url = format!("{}{}", BASE_URL, api_path);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(&[
|
||||
("u", self.config.access.as_str()),
|
||||
("p", &password_md5),
|
||||
("m", &mobile),
|
||||
("c", &content),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.context("SMSBao: HTTP request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.context("SMSBao: failed to read body")?;
|
||||
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("SMSBao: HTTP {} — {}", status, body);
|
||||
}
|
||||
|
||||
parse_smsbao_error(&body)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use anyhow::Context;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::SmsConfig;
|
||||
use crate::sender::Sender;
|
||||
|
||||
pub struct TwilioSender {
|
||||
config: SmsConfig,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl TwilioSender {
|
||||
pub fn new(config: SmsConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Go-style template: replace `{{.code}}` with `code`.
|
||||
fn render_template(template: &str, code: &str) -> String {
|
||||
template.replace("{{.code}}", code)
|
||||
}
|
||||
|
||||
/// Twilio Messages API response (partial — only error fields matter)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TwilioMessageResponse {
|
||||
error_code: Option<i32>,
|
||||
error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Sender for TwilioSender {
|
||||
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
|
||||
let to = format!("+{}{}", area, phone);
|
||||
let body_text = render_template(&self.config.template, code);
|
||||
|
||||
// Twilio REST API: POST /2010-04-01/Accounts/{AccountSid}/Messages.json
|
||||
// Auth: HTTP Basic (AccountSid : AuthToken)
|
||||
let account_sid = &self.config.access;
|
||||
let auth_token = &self.config.secret;
|
||||
let url = format!(
|
||||
"https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json",
|
||||
account_sid
|
||||
);
|
||||
|
||||
let credentials = STANDARD.encode(format!("{}:{}", account_sid, auth_token));
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Basic {}", credentials))
|
||||
.form(&[
|
||||
("To", to.as_str()),
|
||||
("From", self.config.phone_number.as_str()),
|
||||
("Body", body_text.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.context("Twilio: HTTP request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let raw = resp.text().await.context("Twilio: failed to read body")?;
|
||||
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Twilio: HTTP {} — {}", status, raw);
|
||||
}
|
||||
|
||||
let result: TwilioMessageResponse =
|
||||
serde_json::from_str(&raw).context("Twilio: failed to parse response")?;
|
||||
|
||||
if let Some(err_code) = result.error_code {
|
||||
let msg = result.error_message.unwrap_or_default();
|
||||
anyhow::bail!("Twilio: send failed, error_code={}, message={}", err_code, msg);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#[async_trait::async_trait]
|
||||
pub trait Sender: Send + Sync {
|
||||
async fn send(&self, area: &str, phone: &str, code: &str, expire: u32) -> anyhow::Result<()>;
|
||||
}
|
||||
Reference in New Issue
Block a user