This commit is contained in:
Ember Moth
2026-07-05 20:27:58 +08:00
commit 2744c70c5c
837 changed files with 53059 additions and 0 deletions
+16
View File
@@ -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]
+11
View File
@@ -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};
+121
View File
@@ -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()
}
+54
View File
@@ -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,
}]
}
+36
View File
@@ -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())),
}
}
+97
View File
@@ -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(())
}
}
+175
View File
@@ -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>"#;
+263
View File
@@ -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
}
});
}
+12
View File
@@ -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"] }
+116
View File
@@ -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,
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "jwt"
version = "0.1.0"
edition = "2021"
[dependencies]
jsonwebtoken = "9"
serde = { version = "1", features = ["derive"] }
+58
View File
@@ -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)
}
+13
View File
@@ -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"
+24
View File
@@ -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>,
}
+26
View File
@@ -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)
}
}
+98
View File
@@ -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
}
}
+103
View File
@@ -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)
}
+12
View File
@@ -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"
+231
View File
@@ -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-SHA512PPanel 默认)
/// - `"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));
}
}
+16
View File
@@ -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"
+162
View File
@@ -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,
})
}
}
+146
View File
@@ -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(&params);
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 &params {
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(&params));
}
}
+28
View File
@@ -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),
}
+10
View File
@@ -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};
+146
View File
@@ -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"));
}
}
+171
View File
@@ -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?)
}
}
+105
View File
@@ -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,
}
+13
View File
@@ -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"] }
+120
View File
@@ -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:400ErrMsg:Param Error");
}
}
+230
View File
@@ -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));
}
}
+236
View File
@@ -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");
}
}
+9
View File
@@ -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;
+18
View File
@@ -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"
+38
View File
@@ -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,
}
+13
View File
@@ -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)),
}
}
+10
View File
@@ -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;
+33
View File
@@ -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)
}
}
+108
View File
@@ -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(())
}
}
+179
View File
@@ -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(())
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod alibabacloud;
pub mod abosend;
pub mod smsbao;
pub mod twilio;
+86
View File
@@ -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(())
}
}
+82
View File
@@ -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(())
}
}
+4
View File
@@ -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<()>;
}