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
+1
View File
@@ -0,0 +1 @@
/target
Generated
+5535
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
[workspace]
members = ["crates/*"]
[package]
name = "ppanel-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
asynq = { version = "0.1", features = ["json"] }
anyhow = "1"
async-trait = "0.1"
axum = "0.8"
chrono = "0.4"
base64 = "0.22"
hex = "0.4"
jwt = { path = "crates/jwt" }
oauth = { path = "crates/oauth" }
password = { path = "crates/password" }
rand = "0.8"
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
result = { path = "crates/result" }
payment = { path = "crates/payment" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_urlencoded = "0.7"
serde_yaml = "0.9"
sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "mysql", "any", "chrono", "uuid", "migrate", "derive", "macros"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-opentelemetry = "0.28"
opentelemetry = { version = "0.27", features = ["trace"] }
opentelemetry_sdk = { version = "0.27", features = ["rt-tokio", "trace"] }
opentelemetry-otlp = { version = "0.27", features = ["grpc-tonic", "http-proto", "trace"] }
opentelemetry-stdout = { version = "0.27", features = ["trace"] }
opentelemetry-semantic-conventions = "0.27"
uuid = { version = "1", features = ["v4", "v5", "serde"] }
md5 = "0.7"
urlencoding = "2"
gtmpl = "0.7"
gtmpl_value = "0.5"
sms = { path = "crates/sms" }
email = { path = "crates/email" }
reqwest = { version = "0.12", features = ["json"] }
+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<()>;
}
+594
View File
@@ -0,0 +1,594 @@
-- migrate:up
-- 000001_init_schema.up.sql
SET FOREIGN_KEY_CHECKS = 0;
CREATE TABLE IF NOT EXISTS `ads`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Ads title',
`type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Ads type',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Ads content',
`target_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Ads target url',
`start_time` datetime DEFAULT NULL COMMENT 'Ads start time',
`end_time` datetime DEFAULT NULL COMMENT 'Ads end time',
`status` tinyint(1) DEFAULT '0' COMMENT 'Ads status,0 disable,1 enable',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `announcement`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Title',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Content',
`show` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Show',
`pinned` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Pinned',
`popup` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Popup',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `application`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '应用名称',
`icon` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '应用图标',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '更新描述',
`subscribe_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '订阅类型',
`created_at` datetime(3) DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `application_config`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`app_id` bigint NOT NULL DEFAULT '0' COMMENT 'App id',
`encryption_key` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Encryption Key',
`encryption_method` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Encryption Method',
`domains` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,
`startup_picture` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,
`startup_picture_skip_time` bigint NOT NULL DEFAULT '0' COMMENT 'Startup Picture Skip Time',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `application_version`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '应用地址',
`version` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '应用版本',
`platform` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '应用平台',
`is_default` tinyint(1) NOT NULL DEFAULT '0' COMMENT '默认版本',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '更新描述',
`application_id` bigint DEFAULT NULL COMMENT '所属应用',
`created_at` datetime(3) DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `fk_application_application_versions` (`application_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `auth_method`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`method` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'method',
`config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'OAuth Configuration',
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is Enabled',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
UNIQUE KEY `uni_auth_method` (`method`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `coupon`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Coupon Name',
`code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Coupon Code',
`count` bigint NOT NULL DEFAULT '0' COMMENT 'Count Limit',
`type` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Coupon Type: 1: Percentage 2: Fixed Amount',
`discount` bigint NOT NULL DEFAULT '0' COMMENT 'Coupon Discount',
`start_time` bigint NOT NULL DEFAULT '0' COMMENT 'Start Time',
`expire_time` bigint NOT NULL DEFAULT '0' COMMENT 'Expire Time',
`user_limit` bigint NOT NULL DEFAULT '0' COMMENT 'User Limit',
`subscribe` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Subscribe Limit',
`used_count` bigint NOT NULL DEFAULT '0' COMMENT 'Used Count',
`enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Enable',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
UNIQUE KEY `uni_coupon_code` (`code`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `document`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Document Title',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Document Content',
`tags` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Document Tags',
`show` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Show',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `message_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'email' COMMENT 'Message Type',
`platform` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'smtp' COMMENT 'Platform',
`to` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'To',
`subject` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Subject',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Content',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Status',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `order`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`parent_id` bigint DEFAULT NULL COMMENT 'Parent Order Id',
`user_id` bigint NOT NULL DEFAULT '0' COMMENT 'User Id',
`order_no` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Order No',
`type` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Order Type: 1: Subscribe, 2: Renewal, 3: ResetTraffic, 4: Recharge',
`quantity` bigint NOT NULL DEFAULT '1' COMMENT 'Quantity',
`price` bigint NOT NULL DEFAULT '0' COMMENT 'Original price',
`amount` bigint NOT NULL DEFAULT '0' COMMENT 'Order Amount',
`gift_amount` bigint NOT NULL DEFAULT '0' COMMENT 'User Gift Amount',
`discount` bigint NOT NULL DEFAULT '0' COMMENT 'Discount Amount',
`coupon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Coupon',
`coupon_discount` bigint NOT NULL DEFAULT '0' COMMENT 'Coupon Discount Amount',
`commission` bigint NOT NULL DEFAULT '0' COMMENT 'Order Commission',
`payment_id` bigint NOT NULL DEFAULT '-1' COMMENT 'Payment Id',
`method` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Payment Method',
`fee_amount` bigint NOT NULL DEFAULT '0' COMMENT 'Fee Amount',
`trade_no` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Trade No',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished',
`subscribe_id` bigint NOT NULL DEFAULT '0' COMMENT 'Subscribe Id',
`subscribe_token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Renewal Subscribe Token',
`is_new` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is New Order',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
UNIQUE KEY `uni_order_order_no` (`order_no`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `payment`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Payment Name',
`platform` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Payment Platform',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Payment Description',
`icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Payment Icon',
`domain` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Notification Domain',
`config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Payment Configuration',
`fee_mode` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Fee Mode: 0: No Fee 1: Percentage 2: Fixed Amount 3: Percentage + Fixed Amount',
`fee_percent` bigint DEFAULT '0' COMMENT 'Fee Percentage',
`fee_amount` bigint DEFAULT '0' COMMENT 'Fixed Fee Amount',
`enable` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is Enabled',
`token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Payment Token',
PRIMARY KEY (`id`),
UNIQUE KEY `uni_payment_token` (`token`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `server`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Node Name',
`tags` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Tags',
`country` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Country',
`city` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'City',
`latitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'latitude',
`longitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'longitude',
`server_addr` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Server Address',
`relay_mode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'none' COMMENT 'Relay Mode',
`relay_node` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Relay Node',
`speed_limit` bigint NOT NULL DEFAULT '0' COMMENT 'Speed Limit',
`traffic_ratio` decimal(4, 2) NOT NULL DEFAULT '0.00' COMMENT 'Traffic Ratio',
`group_id` bigint DEFAULT NULL COMMENT 'Group ID',
`protocol` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Protocol',
`config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Config',
`enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Enabled',
`sort` bigint NOT NULL DEFAULT '0' COMMENT 'Sort',
`last_reported_at` datetime(3) DEFAULT NULL COMMENT 'Last Reported Time',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
KEY `idx_group_id` (`group_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `server_group`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Group Name',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Group Description',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- if `sms` not exist, create it
CREATE TABLE IF NOT EXISTS `sms`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,
`platform` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
`area_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
`telephone` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
`status` tinyint(1) DEFAULT '1',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `subscribe`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Subscribe Name',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Subscribe Description',
`unit_price` bigint NOT NULL DEFAULT '0' COMMENT 'Unit Price',
`unit_time` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Unit Time',
`discount` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Discount',
`replacement` bigint NOT NULL DEFAULT '0' COMMENT 'Replacement',
`inventory` bigint NOT NULL DEFAULT '0' COMMENT 'Inventory',
`traffic` bigint NOT NULL DEFAULT '0' COMMENT 'Traffic',
`speed_limit` bigint NOT NULL DEFAULT '0' COMMENT 'Speed Limit',
`device_limit` bigint NOT NULL DEFAULT '0' COMMENT 'Device Limit',
`quota` bigint NOT NULL DEFAULT '0' COMMENT 'Quota',
`group_id` bigint DEFAULT NULL COMMENT 'Group Id',
`server_group` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Server Group',
`server` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Server',
`show` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Show portal page',
`sell` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Sell',
`sort` bigint NOT NULL DEFAULT '0' COMMENT 'Sort',
`deduction_ratio` bigint DEFAULT '0' COMMENT 'Deduction Ratio',
`allow_deduction` tinyint(1) DEFAULT '1' COMMENT 'Allow deduction',
`reset_cycle` bigint DEFAULT '0' COMMENT 'Reset Cycle: 0: No Reset, 1: 1st, 2: Monthly, 3: Yearly',
`renewal_reset` tinyint(1) DEFAULT '0' COMMENT 'Renew Reset',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `subscribe_group`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Group Name',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Group Description',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `subscribe_type`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '订阅类型',
`mark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '订阅标识',
`created_at` datetime(3) DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `system`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`category` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Category',
`key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Key Name',
`value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Key Value',
`type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Type',
`desc` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Description',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
UNIQUE KEY `uni_system_key` (`key`),
KEY `index_key` (`key`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `ticket`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Title',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Description',
`user_id` bigint NOT NULL DEFAULT '0' COMMENT 'UserId',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Status',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `ticket_follow`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`ticket_id` bigint NOT NULL DEFAULT '0' COMMENT 'TicketId',
`from` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'From',
`type` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Type: 1 text, 2 image',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Content',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `traffic_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`server_id` bigint NOT NULL COMMENT 'Server ID',
`user_id` bigint NOT NULL COMMENT 'User ID',
`subscribe_id` bigint NOT NULL COMMENT 'Subscription ID',
`download` bigint DEFAULT '0' COMMENT 'Download Traffic',
`upload` bigint DEFAULT '0' COMMENT 'Upload Traffic',
`timestamp` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Traffic Log Time',
PRIMARY KEY (`id`),
KEY `idx_subscribe_id` (`subscribe_id`),
KEY `idx_server_id` (`server_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'User Password',
`avatar` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'User Avatar',
`balance` bigint DEFAULT '0' COMMENT 'User Balance',
`telegram` bigint DEFAULT NULL COMMENT 'Telegram Account',
`refer_code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Referral Code',
`referer_id` bigint DEFAULT NULL COMMENT 'Referrer ID',
`commission` bigint DEFAULT '0' COMMENT 'Commission',
`gift_amount` bigint DEFAULT '0' COMMENT 'User Gift Amount',
`enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Is Account Enabled',
`is_admin` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is Admin',
`valid_email` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is Email Verified',
`enable_email_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Email Notifications',
`enable_telegram_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Telegram Notifications',
`enable_balance_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Balance Change Notifications',
`enable_login_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Login Notifications',
`enable_subscribe_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Subscription Notifications',
`enable_trade_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Trade Notifications',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
`deleted_at` datetime(3) DEFAULT NULL COMMENT 'Deletion Time',
`is_del` bigint unsigned DEFAULT NULL COMMENT '1: Normal 0: Deleted',
PRIMARY KEY (`id`),
KEY `idx_referer` (`referer_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_auth_methods`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`auth_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Auth Type 1: apple 2: google 3: github 4: facebook 5: telegram 6: email 7: phone',
`auth_identifier` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Auth Identifier',
`verified` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is Verified',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_auth_identifier` (`auth_identifier`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_balance_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`amount` bigint NOT NULL COMMENT 'Amount',
`type` tinyint(1) NOT NULL COMMENT 'Type: 1: Recharge 2: Withdraw 3: Payment 4: Refund 5: Reward',
`order_id` bigint DEFAULT NULL COMMENT 'Order ID',
`balance` bigint NOT NULL COMMENT 'Balance',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_commission_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`order_no` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Order No.',
`amount` bigint NOT NULL COMMENT 'Amount',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_device`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`subscribe_id` bigint DEFAULT NULL COMMENT 'Subscribe ID',
`ip` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Ip.',
`Identifier` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Identifier.',
`user_agent` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device User Agent.',
`online` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Online',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'EnableDeviceNumber',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_gift_amount_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`user_subscribe_id` bigint DEFAULT NULL COMMENT 'Deduction User Subscribe ID',
`order_no` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Order No.',
`type` tinyint(1) NOT NULL COMMENT 'Type: 1: Increase 2: Reduce',
`amount` bigint NOT NULL COMMENT 'Amount',
`balance` bigint NOT NULL COMMENT 'Balance',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Remark',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_login_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`login_ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Login IP',
`user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'UserAgent',
`success` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Login Success',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_subscribe`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`order_id` bigint NOT NULL COMMENT 'Order ID',
`subscribe_id` bigint NOT NULL COMMENT 'Subscription ID',
`start_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Subscription Start Time',
`expire_time` datetime(3) DEFAULT NULL COMMENT 'Subscription Expire Time',
`traffic` bigint DEFAULT '0' COMMENT 'Traffic',
`download` bigint DEFAULT '0' COMMENT 'Download Traffic',
`upload` bigint DEFAULT '0' COMMENT 'Upload Traffic',
`token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Token',
`uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'UUID',
`status` tinyint(1) DEFAULT '0' COMMENT 'Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired 4: Deducted',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
UNIQUE KEY `uni_user_subscribe_token` (`token`),
UNIQUE KEY `uni_user_subscribe_uuid` (`uuid`),
KEY `idx_user_id` (`user_id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_subscribe_id` (`subscribe_id`),
KEY `idx_token` (`token`),
KEY `idx_uuid` (`uuid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_subscribe_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`user_subscribe_id` bigint NOT NULL COMMENT 'User Subscribe ID',
`token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Token',
`ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'IP',
`user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'UserAgent',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_user_subscribe_id` (`user_subscribe_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `server_rule_group`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Rule Group Name',
`icon` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Rule Group Icon',
`tags` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Selected Node Tags',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Rule Group Description',
`enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Rule Group Enable',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
UNIQUE KEY `unique_name` (`name`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
SET FOREIGN_KEY_CHECKS = 1;
-- migrate:down
-- 000001_init_schema.down.sql
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `user_subscribe_log`;
DROP TABLE IF EXISTS `user_subscribe`;
DROP TABLE IF EXISTS `user_login_log`;
DROP TABLE IF EXISTS `user_gift_amount_log`;
DROP TABLE IF EXISTS `user_device`;
DROP TABLE IF EXISTS `user_commission_log`;
DROP TABLE IF EXISTS `user_balance_log`;
DROP TABLE IF EXISTS `user_auth_methods`;
DROP TABLE IF EXISTS `user`;
DROP TABLE IF EXISTS `traffic_log`;
DROP TABLE IF EXISTS `ticket_follow`;
DROP TABLE IF EXISTS `ticket`;
DROP TABLE IF EXISTS `system`;
DROP TABLE IF EXISTS `subscribe_type`;
DROP TABLE IF EXISTS `subscribe_group`;
DROP TABLE IF EXISTS `subscribe`;
DROP TABLE IF EXISTS `sms`;
DROP TABLE IF EXISTS `server_rule_group`;
DROP TABLE IF EXISTS `server_group`;
DROP TABLE IF EXISTS `server`;
DROP TABLE IF EXISTS `payment`;
DROP TABLE IF EXISTS `order`;
DROP TABLE IF EXISTS `message_log`;
DROP TABLE IF EXISTS `document`;
DROP TABLE IF EXISTS `coupon`;
DROP TABLE IF EXISTS `auth_method`;
DROP TABLE IF EXISTS `application_version`;
DROP TABLE IF EXISTS `application_config`;
DROP TABLE IF EXISTS `application`;
DROP TABLE IF EXISTS `announcement`;
DROP TABLE IF EXISTS `ads`;
SET FOREIGN_KEY_CHECKS = 1;
+150
View File
@@ -0,0 +1,150 @@
-- migrate:up
-- 000002_init_data.up.sql
SET FOREIGN_KEY_CHECKS = 0;
-- auth_method
INSERT IGNORE INTO `auth_method` (`id`, `method`, `config`, `enabled`, `created_at`, `updated_at`)
VALUES (1, 'email',
'{"platform":"smtp","platform_config":{"host":"","port":0,"user":"","pass":"","from":"","ssl":false},"enable_verify":false,"enable_notify":false,"enable_domain_suffix":false,"domain_suffix_list":"","verify_email_template":"","expiration_email_template":"","maintenance_email_template":"","traffic_exceed_email_template":""}',
1, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'),
(2, 'mobile',
'{"platform":"AlibabaCloud","platform_config":{"access":"","secret":"","sign_name":"","endpoint":"","template_code":""},"enable_whitelist":false,"whitelist":[]}',
0, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'),
(3, 'apple', '{"team_id":"","key_id":"","client_id":"","client_secret":"","redirect_url":""}', 0,
'2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'),
(4, 'google', '{"client_id":"","client_secret":"","redirect_url":""}', 0, '2025-04-22 14:25:16.642',
'2025-04-22 14:25:16.642'),
(5, 'github', '{"client_id":"","client_secret":"","redirect_url":""}', 0, '2025-04-22 14:25:16.642',
'2025-04-22 14:25:16.642'),
(6, 'facebook', '{"client_id":"","client_secret":"","redirect_url":""}', 0, '2025-04-22 14:25:16.642',
'2025-04-22 14:25:16.642'),
(7, 'telegram', '{"bot_token":"","enable_notify":false,"webhook_domain":""}', 0, '2025-04-22 14:25:16.642',
'2025-04-22 14:25:16.642'),
(8, 'device', '{"show_ads":false,"only_real_device":false,"enable_security":false,"security_secret":""}', 0,
'2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642');
-- payment
INSERT IGNORE INTO `payment` (`id`, `name`, `platform`, `description`, `icon`, `domain`, `config`, `fee_mode`,
`fee_percent`, `fee_amount`, `enable`, `token`)
VALUES (-1, 'Balance', 'balance', '', '', '', '', 0, 0, 0, 1, '');
-- subscribe_type
INSERT IGNORE INTO `subscribe_type` (`id`, `name`, `mark`, `created_at`, `updated_at`)
VALUES (1, 'Clash', 'Clash', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(2, 'Hiddify', 'Hiddify', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(3, 'Loon', 'Loon', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(4, 'NekoBox', 'NekoBox', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(5, 'NekoRay', 'NekoRay', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(6, 'Netch', 'Netch', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(7, 'Quantumult', 'Quantumult', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(8, 'Shadowrocket', 'Shadowrocket', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(9, 'SingBox', ' SingBox', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(10, 'Surfboard', 'Surfboard', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(11, 'Surge', 'Surge', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(12, 'V2box', 'V2box', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(13, 'V2rayN', 'V2rayN', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(14, 'V2rayNg', 'V2rayNg', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648');
-- system
INSERT IGNORE INTO `system` (`id`, `category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
VALUES (1, 'site', 'SiteLogo', '/favicon.svg', 'string', 'Site Logo', '2025-04-22 14:25:16.637',
'2025-04-22 14:25:16.637'),
(2, 'site', 'SiteName', 'Perfect Panel', 'string', 'Site Name', '2025-04-22 14:25:16.637',
'2025-04-22 14:25:16.637'),
(3, 'site', 'SiteDesc',
'PPanel is a pure, professional, and perfect open-source proxy panel tool, designed to be your ideal choice for learning and practical use.',
'string', 'Site Description', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
(4, 'site', 'Host', '', 'string', 'Site Host', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
(5, 'site', 'Keywords', 'Perfect Panel,PPanel', 'string', 'Site Keywords', '2025-04-22 14:25:16.637',
'2025-04-22 14:25:16.637'),
(6, 'site', 'CustomHTML', '', 'string', 'Custom HTML', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
(7, 'tos', 'TosContent', 'Welcome to use Perfect Panel', 'string', 'Terms of Service', '2025-04-22 14:25:16.637',
'2025-04-22 14:25:16.637'),
(8, 'tos', 'PrivacyPolicy', '', 'string', 'PrivacyPolicy', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
(9, 'ad', 'WebAD', 'false', 'bool', 'Display ad on the web', '2025-04-22 14:25:16.637',
'2025-04-22 14:25:16.637'),
(10, 'subscribe', 'SingleModel', 'false', 'bool', '是否单订阅模式', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(11, 'subscribe', 'SubscribePath', '/api/subscribe', 'string', '订阅路径', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(12, 'subscribe', 'SubscribeDomain', '', 'string', '订阅域名', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(13, 'subscribe', 'PanDomain', 'false', 'bool', '是否使用泛域名', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(14, 'verify', 'TurnstileSiteKey', '', 'string', 'TurnstileSiteKey', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(15, 'verify', 'TurnstileSecret', '', 'string', 'TurnstileSecret', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(16, 'verify', 'EnableLoginVerify', 'false', 'bool', 'is enable login verify', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(17, 'verify', 'EnableRegisterVerify', 'false', 'bool', 'is enable register verify', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(18, 'verify', 'EnableResetPasswordVerify', 'false', 'bool', 'is enable reset password verify',
'2025-04-22 14:25:16.639', '2025-04-22 14:25:16.639'),
(19, 'server', 'NodeSecret', '12345678', 'string', 'node secret', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(20, 'server', 'NodePullInterval', '10', 'int', 'node pull interval', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(21, 'server', 'NodePushInterval', '60', 'int', 'node push interval', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(22, 'server', 'NodeMultiplierConfig', '[]', 'string', 'node multiplier config', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(23, 'invite', 'ForcedInvite', 'false', 'bool', 'Forced invite', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(24, 'invite', 'ReferralPercentage', '20', 'int', 'Referral percentage', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(25, 'invite', 'OnlyFirstPurchase', 'false', 'bool', 'Only first purchase', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(26, 'register', 'StopRegister', 'false', 'bool', 'is stop register', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(27, 'register', 'EnableTrial', 'false', 'bool', 'is enable trial', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(28, 'register', 'TrialSubscribe', '', 'int', 'Trial subscription', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(29, 'register', 'TrialTime', '24', 'int', 'Trial time', '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'),
(30, 'register', 'TrialTimeUnit', 'Hour', 'string', 'Trial time unit', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(31, 'register', 'EnableIpRegisterLimit', 'false', 'bool', 'is enable IP register limit',
'2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'),
(32, 'register', 'IpRegisterLimit', '3', 'int', 'IP Register Limit', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(33, 'register', 'IpRegisterLimitDuration', '64', 'int', 'IP Register Limit Duration (minutes)',
'2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'),
(34, 'currency', 'Currency', 'USD', 'string', 'Currency', '2025-04-22 14:25:16.641', '2025-04-22 14:25:16.641'),
(35, 'currency', 'CurrencySymbol', '$', 'string', 'Currency Symbol', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(36, 'currency', 'CurrencyUnit', 'USD', 'string', 'Currency Unit', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(37, 'currency', 'AccessKey', '', 'string', 'Exchangerate Access Key', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(38, 'verify_code', 'VerifyCodeExpireTime', '300', 'int', 'Verify code expire time', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(39, 'verify_code', 'VerifyCodeLimit', '15', 'int', 'limits of verify code', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(40, 'verify_code', 'VerifyCodeInterval', '60', 'int', 'Interval of verify code', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(41, 'system', 'Version', '0.2.0(02002)', 'string', 'System Version', '2025-04-22 14:25:16.642',
'2025-04-22 14:25:16.642');
SET FOREIGN_KEY_CHECKS = 1;
-- migrate:down
-- 000002_init_data.down.sql
SET
FOREIGN_KEY_CHECKS = 0;
DELETE
FROM `auth_method`
WHERE `id` IN (1, 2, 3, 4, 5, 6, 7, 8);
DELETE
FROM `payment`
WHERE `id` = -1;
DELETE
FROM `subscribe_type`
WHERE `id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14);
DELETE
FROM `system`
WHERE `id` IN
(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41);
SET
FOREIGN_KEY_CHECKS = 1;
+146
View File
@@ -0,0 +1,146 @@
-- migrate:up
-- 2025-04-22 16:16:00
-- Purpose: Update payment table
-- Author: PPanel Team, 2025-04-21
SET FOREIGN_KEY_CHECKS = 0;
-- Alter the order table to add a payment_id column (if not exists)
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'order'
AND COLUMN_NAME = 'payment_id');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `order` ADD COLUMN `payment_id` bigint NOT NULL DEFAULT \'-1\' COMMENT \'Payment Id\' AFTER `commission`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Alter the payment table to add a platform column (if not exists)
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND COLUMN_NAME = 'platform');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `payment` ADD COLUMN `platform` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT \'Payment Platform\' AFTER `name`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Drop the mark column from the payment table (only if exists)
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND COLUMN_NAME = 'mark');
SET @sql = IF(@column_exists > 0,
'ALTER TABLE `payment` DROP COLUMN `mark`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Alter the payment table to add a description column (if not exists)
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND COLUMN_NAME = 'description');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `payment` ADD COLUMN `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT \'Payment Description\' AFTER `platform`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Alter the payment table to add a token column (if not exists)
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND COLUMN_NAME = 'token');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `payment` ADD COLUMN `token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT \'Payment Token\' AFTER `description`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET FOREIGN_KEY_CHECKS = 1;
-- migrate:down
-- migrations/02003_update_payment.down.sql
-- Purpose: Revert updates to payment and order tables
-- Author: PPanel Team, 2025-04-21
SET FOREIGN_KEY_CHECKS = 0;
-- Drop payment_id column from order table (if exists)
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'order'
AND COLUMN_NAME = 'payment_id');
SET @sql = IF(@column_exists > 0,
'ALTER TABLE `order` DROP COLUMN `payment_id`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Drop platform column from payment table (if exists)
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND COLUMN_NAME = 'platform');
SET @sql = IF(@column_exists > 0,
'ALTER TABLE `payment` DROP COLUMN `platform`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Drop description column from payment table (if exists)
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND COLUMN_NAME = 'description');
SET @sql = IF(@column_exists > 0,
'ALTER TABLE `payment` DROP COLUMN `description`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Drop token column from payment table (if exists)
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND COLUMN_NAME = 'token');
SET @sql = IF(@column_exists > 0,
'ALTER TABLE `payment` DROP COLUMN `token`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Optionally restore mark column (if needed, adjust definition as per original schema)
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND COLUMN_NAME = 'mark');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `payment` ADD COLUMN `mark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT \'Payment Mark\' AFTER `name`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET FOREIGN_KEY_CHECKS = 1;
+28
View File
@@ -0,0 +1,28 @@
-- migrate:up
-- migrations/02003_rebuild_rule.up.sql
-- Purpose: rebuilding server rule table
-- Author: PPanel Team, 2025-04-21
DROP TABLE IF EXISTS `server_rule_group`;
CREATE TABLE `server_rule_group`
(
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Rule Group Name',
`icon` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Rule Group Icon',
`tags` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Selected Node Tags',
`rules` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Rules',
`enable` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Rule Group Enable',
`created_at` DATETIME(3) COMMENT 'Creation Time',
`updated_at` DATETIME(3) COMMENT 'Update Time',
PRIMARY KEY (`id`),
UNIQUE KEY `uni_server_rule_group_name` (`name`),
INDEX `idx_enable` (`enable`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- migrate:down
-- migrations/02003_rebuild_rule.up.sql
-- Purpose: Back rebuilding server rule table
-- Author: PPanel Team, 2025-04-21
DROP TABLE IF EXISTS server_rule_group;
@@ -0,0 +1,124 @@
-- migrate:up
-- migrations/02005_create_user_device_online_record.up.sql
-- Purpose: Create table for tracking user device online records
-- Author: PPanel Team, 2025-04-22
CREATE TABLE IF NOT EXISTS `user_device_online_record`
(
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`user_id` BIGINT NOT NULL COMMENT 'User ID',
`identifier` VARCHAR(255) NOT NULL COMMENT 'Device Identifier',
`online_time` DATETIME COMMENT 'Online Time',
`offline_time` DATETIME COMMENT 'Offline Time',
`online_seconds` BIGINT COMMENT 'Offline Seconds',
`duration_days` BIGINT COMMENT 'Duration Days',
`created_at` DATETIME COMMENT 'Creation Time'
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- User subscribe table migration for adding finished_at column
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_subscribe'
AND COLUMN_NAME = 'finished_at');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `user_subscribe` ADD COLUMN `finished_at` DATETIME NULL COMMENT ''Subscribe Finished Time'' AFTER `expire_time`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Application config table migration for adding Link column
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'application_config'
AND COLUMN_NAME = 'invitation_link');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `application_config` ADD COLUMN `invitation_link` TEXT NULL DEFAULT NULL COMMENT ''Invitation Link'' AFTER `startup_picture_skip_time`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Application config table migration for adding kr_website_id column
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'application_config'
AND COLUMN_NAME = 'kr_website_id');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `application_config` ADD COLUMN `kr_website_id` VARCHAR(255) NULL DEFAULT NULL COMMENT ''KR Website ID'' AFTER `invitation_link`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- migrate:down
-- migrations/02004_create_user_device_online_record.down.sql
-- Purpose: Drop user device online record table
-- Author: PPanel Team, 2025-04-22
DROP TABLE IF EXISTS `user_device_online_record`;
-- User subscribe table migration for removing finished_at column
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_subscribe'
AND COLUMN_NAME = 'finished_at');
SET @sql = IF(@column_exists > 0,
'ALTER TABLE `user_subscribe` DROP COLUMN `finished_at`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Application config table migration for removing invitation_link column
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'application_config'
AND COLUMN_NAME = 'invitation_link');
SET @sql = IF(@column_exists > 0,
'ALTER TABLE `application_config` DROP COLUMN `invitation_link`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Application config table migration for removing kr_website_id column
SET @column_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'application_config'
AND COLUMN_NAME = 'kr_website_id');
SET @sql = IF(@column_exists > 0,
'ALTER TABLE `application_config` DROP COLUMN `kr_website_id`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,26 @@
-- migrate:up
-- migrations/02008_create_user_reset_subscribe_log.up.sql
-- Purpose: Create user_reset_subscribe_log table
-- Author: PPanel Team, 2025-04-22
CREATE TABLE IF NOT EXISTS `user_reset_subscribe_log`
(
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`user_id` BIGINT NOT NULL COMMENT 'User ID',
`type` TINYINT(1) NOT NULL COMMENT 'Type: 1: Auto 2: Advance 3: Paid',
`order_no` VARCHAR(255) DEFAULT NULL COMMENT 'Order No.',
`user_subscribe_id` BIGINT NOT NULL COMMENT 'User Subscribe ID',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Creation Time',
INDEX `idx_user_id` (`user_id`),
INDEX `idx_user_subscribe_id` (`user_subscribe_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- migrate:down
-- migrations/02008_create_user_reset_subscribe_log.down.sql
-- Purpose: Drop user_reset_subscribe_log table
-- Author: PPanel Team, 2025-04-22
DROP TABLE IF EXISTS `user_reset_subscribe_log`;
+9
View File
@@ -0,0 +1,9 @@
-- migrate:up
ALTER TABLE `server_rule_group`
ADD COLUMN `default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Is Default Group',
ADD COLUMN `type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'Rule Group Type';
-- migrate:down
ALTER TABLE `server_rule_group`
DROP COLUMN `default`,
DROP COLUMN `type`;
+27
View File
@@ -0,0 +1,27 @@
-- migrate:up
DROP TABLE IF EXISTS `email_task`;
CREATE TABLE `email_task` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`subject` varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Subject',
`content` text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Content',
`recipient` text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Recipient',
`scope` varchar(50) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Scope',
`register_start_time` datetime(3) DEFAULT NULL COMMENT 'Register Start Time',
`register_end_time` datetime(3) DEFAULT NULL COMMENT 'Register End Time',
`additional` text COLLATE utf8mb4_general_ci COMMENT 'Additional Information',
`scheduled` datetime(3) NOT NULL COMMENT 'Scheduled Time',
`interval` tinyint unsigned NOT NULL COMMENT 'Interval in Seconds',
`limit` bigint unsigned NOT NULL COMMENT 'Daily send limit',
`status` tinyint unsigned NOT NULL COMMENT 'Daily Status',
`errors` text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Errors',
`total` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Total Number',
`current` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Current Number',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
SET FOREIGN_KEY_CHECKS = 1;
-- migrate:down
DROP TABLE IF EXISTS `email_task`;
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
-- migrate:up
INSERT IGNORE INTO `system` (`id`, `category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
VALUES
(42, 'subscribe', 'UserAgentLimit', 'false', 'bool', 'User Agent Limit', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
(43, 'subscribe', 'UserAgentList', '', 'string', 'User Agent List', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637');
-- migrate:down
@@ -0,0 +1,6 @@
-- migrate:up
DROP TABLE IF EXISTS `application`;
DROP TABLE IF EXISTS `application_version`;
DROP TABLE IF EXISTS `application_config`;
-- migrate:down
+127
View File
@@ -0,0 +1,127 @@
-- migrate:up
DROP TABLE IF EXISTS `user_balance_log`;
DROP TABLE IF EXISTS `user_commission_log`;
DROP TABLE IF EXISTS `user_gift_amount_log`;
DROP TABLE IF EXISTS `user_login_log`;
DROP TABLE IF EXISTS `user_reset_subscribe_log`;
DROP TABLE IF EXISTS `user_subscribe_log`;
DROP TABLE IF EXISTS `message_log`;
DROP TABLE IF EXISTS `system_logs`;
CREATE TABLE `system_logs` (
`id` bigint NOT NULL AUTO_INCREMENT,
`type` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Log Type: 1: Email Message 2: Mobile Message 3: Subscribe 4: Subscribe Traffic 5: Server Traffic 6: Login 7: Register 8: Balance 9: Commission 10: Reset Subscribe 11: Gift',
`date` varchar(20) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Log Date',
`object_id` bigint NOT NULL DEFAULT '0' COMMENT 'Object ID',
`content` text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Log Content',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
PRIMARY KEY (`id`),
KEY `idx_type` (`type`),
KEY `idx_object_id` (`object_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- migrate:down
CREATE TABLE IF NOT EXISTS `user_balance_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`amount` bigint NOT NULL COMMENT 'Amount',
`type` tinyint(1) NOT NULL COMMENT 'Type: 1: Recharge 2: Withdraw 3: Payment 4: Refund 5: Reward',
`order_id` bigint DEFAULT NULL COMMENT 'Order ID',
`balance` bigint NOT NULL COMMENT 'Balance',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_commission_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`order_no` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Order No.',
`amount` bigint NOT NULL COMMENT 'Amount',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_gift_amount_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`user_subscribe_id` bigint DEFAULT NULL COMMENT 'Deduction User Subscribe ID',
`order_no` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Order No.',
`type` tinyint(1) NOT NULL COMMENT 'Type: 1: Increase 2: Reduce',
`amount` bigint NOT NULL COMMENT 'Amount',
`balance` bigint NOT NULL COMMENT 'Balance',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Remark',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_login_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`login_ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Login IP',
`user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'UserAgent',
`success` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Login Success',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_reset_subscribe_log`
(
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`user_id` BIGINT NOT NULL COMMENT 'User ID',
`type` TINYINT(1) NOT NULL COMMENT 'Type: 1: Auto 2: Advance 3: Paid',
`order_no` VARCHAR(255) DEFAULT NULL COMMENT 'Order No.',
`user_subscribe_id` BIGINT NOT NULL COMMENT 'User Subscribe ID',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Creation Time',
INDEX `idx_user_id` (`user_id`),
INDEX `idx_user_subscribe_id` (`user_subscribe_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_subscribe_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT 'User ID',
`user_subscribe_id` bigint NOT NULL COMMENT 'User Subscribe ID',
`token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Token',
`ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'IP',
`user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'UserAgent',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_user_subscribe_id` (`user_subscribe_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `message_log`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'email' COMMENT 'Message Type',
`platform` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'smtp' COMMENT 'Platform',
`to` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'To',
`subject` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Subject',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Content',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Status',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
DROP TABLE IF EXISTS `system_logs`;
+34
View File
@@ -0,0 +1,34 @@
-- migrate:up
CREATE TABLE IF NOT EXISTS `servers` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(100) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Server Name',
`country` varchar(128) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Country',
`city` varchar(128) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'City',
`ratio` decimal(4,2) NOT NULL DEFAULT '0.00' COMMENT 'Traffic Ratio',
`address` varchar(100) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Server Address',
`sort` bigint NOT NULL DEFAULT '0' COMMENT 'Sort',
`protocols` text COLLATE utf8mb4_general_ci COMMENT 'Protocol',
`last_reported_at` datetime(3) DEFAULT NULL COMMENT 'Last Reported Time',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `nodes` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(100) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Node Name',
`tags` varchar(255) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Tags',
`port` smallint unsigned NOT NULL DEFAULT '0' COMMENT 'Connect Port',
`address` varchar(255) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Connect Address',
`server_id` bigint NOT NULL DEFAULT '0' COMMENT 'Server ID',
`protocol` varchar(100) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Protocol',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Enabled',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- migrate:down
DROP TABLE IF EXISTS `nodes`;
DROP TABLE IF EXISTS `servers`;
+16
View File
@@ -0,0 +1,16 @@
-- migrate:up
ALTER TABLE `subscribe`
ADD COLUMN `nodes` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Node IDs',
ADD COLUMN `node_tags` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Node Tags',
DROP COLUMN `server`,
DROP COLUMN `server_group`;
DROP TABLE IF EXISTS `server_rule_group`;
-- migrate:down
ALTER TABLE `subscribe`
DROP COLUMN `nodes`,
DROP COLUMN `node_tags`,
ADD COLUMN `server` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Server',
ADD COLUMN `server_group` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Server Group';
+7
View File
@@ -0,0 +1,7 @@
-- migrate:up
INSERT IGNORE INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
VALUES
('log', 'AutoClear', 'true', 'bool', 'Auto Clear Log', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
('log', 'ClearDays', '7', 'int', 'Clear Days', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637');
-- migrate:down
+13
View File
@@ -0,0 +1,13 @@
-- migrate:up
ALTER TABLE `user`
ADD COLUMN `referral_percentage` TINYINT UNSIGNED NOT NULL DEFAULT 0
COMMENT 'Referral Percentage'
AFTER `commission`,
ADD COLUMN `only_first_purchase` TINYINT(1) NOT NULL DEFAULT 1
COMMENT 'Only First Purchase'
AFTER `referral_percentage`;
-- migrate:down
ALTER TABLE `user`
DROP COLUMN `referral_percentage`,
DROP COLUMN `only_first_purchase`;
+7
View File
@@ -0,0 +1,7 @@
-- migrate:up
ALTER TABLE `nodes`
ADD COLUMN `sort` INT UNSIGNED NOT NULL DEFAULT 0
COMMENT 'Sort' AFTER `enabled`;
-- migrate:down
ALTER TABLE `nodes`
DROP COLUMN `sort`;
@@ -0,0 +1,6 @@
-- migrate:up
CREATE INDEX idx_traffic_log_time_user_sub ON traffic_log (timestamp, user_id, subscribe_id);
-- migrate:down
DROP INDEX idx_traffic_log_time_user_sub ON traffic_log;
+6
View File
@@ -0,0 +1,6 @@
-- migrate:up
DROP TABLE IF EXISTS `subscribe_type`;
DROP TABLE IF EXISTS `sms`;
-- migrate:down
DROP TABLE IF EXISTS `subscribe_type`;
DROP TABLE IF EXISTS `sms`;
+10
View File
@@ -0,0 +1,10 @@
-- migrate:up
ALTER TABLE `subscribe`
DROP COLUMN `group_id`,
ADD COLUMN `language` VARCHAR(255) NOT NULL DEFAULT ''
COMMENT 'Language'
AFTER `name`;
DROP TABLE IF EXISTS `subscribe_group`;
-- migrate:down
+17
View File
@@ -0,0 +1,17 @@
-- migrate:up
DROP TABLE IF EXISTS `email_task`;
CREATE TABLE `task` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`type` tinyint NOT NULL COMMENT 'Task Type',
`scope` text COLLATE utf8mb4_general_ci COMMENT 'Task Scope',
`content` text COLLATE utf8mb4_general_ci COMMENT 'Task Content',
`status` tinyint NOT NULL DEFAULT '0' COMMENT 'Task Status: 0: Pending, 1: In Progress, 2: Completed, 3: Failed',
`errors` text COLLATE utf8mb4_general_ci COMMENT 'Task Errors',
`total` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Total Number',
`current` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Current Number',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- migrate:down
+11
View File
@@ -0,0 +1,11 @@
-- migrate:up
INSERT
IGNORE INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
VALUE
('server', 'TrafficReportThreshold', '0', 'int', 'Traffic report threshold', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'),
('server', 'IPStrategy', '', 'string', 'IP Strategy', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'),
('server', 'DNS', '', 'string', 'DNS', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'),
('server', 'Block', '', 'string', 'Block', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'),
('server', 'Outbound', '', 'string', 'Proxy Outbound', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637');
-- migrate:down
+24
View File
@@ -0,0 +1,24 @@
-- migrate:up
-- 只有当 ads 表中不存在 description 字段时才添加
SET
@col_exists := (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ads'
AND COLUMN_NAME = 'description'
);
SET
@query := IF(
@col_exists = 0,
'ALTER TABLE `ads` ADD COLUMN `description` VARCHAR(255) DEFAULT '''' COMMENT ''Description'';',
'SELECT "Column `description` already exists"'
);
PREPARE stmt FROM @query;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- migrate:down
+42
View File
@@ -0,0 +1,42 @@
-- migrate:up
-- 添加 algo 列(如果不存在)
SET @dbname = DATABASE();
SET @tablename = 'user';
SET @colname = 'algo';
SET @sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `user` ADD COLUMN `algo` VARCHAR(20) NOT NULL DEFAULT ''default'' COMMENT ''Encryption Algorithm'' AFTER `password`;',
'SELECT "Column `algo` already exists";'
)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @dbname
AND TABLE_NAME = @tablename
AND COLUMN_NAME = @colname
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 添加 salt 列(如果不存在)
SET @colname = 'salt';
SET @sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `user` ADD COLUMN `salt` VARCHAR(20) NOT NULL DEFAULT ''default'' COMMENT ''Password Salt'' AFTER `algo`;',
'SELECT "Column `salt` already exists";'
)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @dbname
AND TABLE_NAME = @tablename
AND COLUMN_NAME = @colname
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- migrate:down
ALTER TABLE `user`
DROP COLUMN `algo`,
DROP COLUMN `salt`;
@@ -0,0 +1,18 @@
-- migrate:up
INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
SELECT 'site', 'CustomData', '{
"kr_website_id": ""
}', 'string', 'Custom Data', '2025-04-22 14:25:16.637', '2025-10-14 15:47:19.187'
WHERE NOT EXISTS (
SELECT 1 FROM `system` WHERE `category` = 'site' AND `key` = 'CustomData'
);
-- migrate:down
INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
SELECT 'site', 'CustomData', '{
"kr_website_id": ""
}', 'string', 'Custom Data', '2025-04-22 14:25:16.637', '2025-10-14 15:47:19.187'
WHERE NOT EXISTS (
SELECT 1 FROM `system` WHERE `category` = 'site' AND `key` = 'CustomData'
);
@@ -0,0 +1,6 @@
-- migrate:up
ALTER TABLE traffic_log ADD INDEX idx_timestamp (timestamp);
-- migrate:down
ALTER TABLE traffic_log DROP INDEX idx_timestamp;
@@ -0,0 +1,10 @@
-- migrate:up
ALTER TABLE `user_subscribe`
ADD COLUMN `note` VARCHAR(500) NOT NULL DEFAULT ''
COMMENT 'User note for subscription'
AFTER `status`;
-- migrate:down
ALTER TABLE `user_subscribe`
DROP COLUMN `note`;
+10
View File
@@ -0,0 +1,10 @@
-- migrate:up
ALTER TABLE `user`
ADD COLUMN `rules` TEXT NULL
COMMENT 'User rules for subscription'
AFTER `created_at`;
-- migrate:down
ALTER TABLE `user`
DROP COLUMN IF EXISTS `rules`;
@@ -0,0 +1,24 @@
-- migrate:up
CREATE TABLE IF NOT EXISTS `withdrawals` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Primary Key',
`user_id` BIGINT NOT NULL COMMENT 'User ID',
`amount` BIGINT NOT NULL COMMENT 'Withdrawal Amount',
`content` TEXT COMMENT 'Withdrawal Content',
`status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Withdrawal Status',
`reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Rejection Reason',
`created_at` DATETIME NOT NULL COMMENT 'Creation Time',
`updated_at` DATETIME NOT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
VALUES
('invite', 'WithdrawalMethod', '', 'string', 'withdrawal method', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637');
-- migrate:down
DROP TABLE IF EXISTS `withdrawals`;
DELETE FROM `system`
WHERE `category` = 'invite'
AND `key` = 'WithdrawalMethod';
+31
View File
@@ -0,0 +1,31 @@
-- migrate:up
DROP TABLE IF EXISTS `server`;
-- migrate:down
CREATE TABLE IF NOT EXISTS `server`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Node Name',
`tags` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Tags',
`country` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Country',
`city` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'City',
`latitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'latitude',
`longitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'longitude',
`server_addr` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Server Address',
`relay_mode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'none' COMMENT 'Relay Mode',
`relay_node` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Relay Node',
`speed_limit` bigint NOT NULL DEFAULT '0' COMMENT 'Speed Limit',
`traffic_ratio` decimal(4, 2) NOT NULL DEFAULT '0.00' COMMENT 'Traffic Ratio',
`group_id` bigint DEFAULT NULL COMMENT 'Group ID',
`protocol` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Protocol',
`config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Config',
`enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Enabled',
`sort` bigint NOT NULL DEFAULT '0' COMMENT 'Sort',
`last_reported_at` datetime(3) DEFAULT NULL COMMENT 'Last Reported Time',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
KEY `idx_group_id` (`group_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
@@ -0,0 +1,8 @@
-- migrate:up
ALTER TABLE `subscribe`
ADD COLUMN `show_original_price` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'display the original price: 0 not display, 1 display' AFTER `created_at`;
-- migrate:down
ALTER TABLE `subscribe`
DROP COLUMN `show_original_price`;
@@ -0,0 +1,4 @@
-- migrate:up
DROP TABLE IF EXISTS `server_group`;
-- migrate:down
@@ -0,0 +1,11 @@
-- migrate:up
-- Update the `subscribe` table to set `inventory` to -1 where it is currently 0
UPDATE `subscribe`
SET `inventory` = -1
WHERE `inventory` = 0;
-- migrate:down
-- This migration script reverts the inventory values in the 'subscribe' table
UPDATE `subscribe`
SET `inventory` = 0
WHERE `inventory` = -1;
@@ -0,0 +1,6 @@
-- migrate:up
CREATE INDEX idx_type_date ON system_logs (type, date);
-- migrate:down
DROP INDEX idx_type_date ON system_logs;
+266
View File
@@ -0,0 +1,266 @@
-- migrate:up
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'order'
AND INDEX_NAME = 'idx_order_trade_no');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `order` ADD INDEX `idx_order_trade_no` (`trade_no`(191))',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'order'
AND INDEX_NAME = 'idx_order_coupon');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `order` ADD INDEX `idx_order_coupon` (`coupon`(191))',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_refer_code');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `user` ADD INDEX `idx_user_refer_code` (`refer_code`)',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'coupon'
AND INDEX_NAME = 'idx_coupon_name');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `coupon` ADD INDEX `idx_coupon_name` (`name`(191))',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND INDEX_NAME = 'idx_payment_name');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `payment` ADD INDEX `idx_payment_name` (`name`)',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'servers'
AND INDEX_NAME = 'idx_servers_name');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `servers` ADD INDEX `idx_servers_name` (`name`)',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'servers'
AND INDEX_NAME = 'idx_servers_address');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `servers` ADD INDEX `idx_servers_address` (`address`)',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nodes'
AND INDEX_NAME = 'idx_nodes_name');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `nodes` ADD INDEX `idx_nodes_name` (`name`)',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nodes'
AND INDEX_NAME = 'idx_nodes_address');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `nodes` ADD INDEX `idx_nodes_address` (`address`(191))',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nodes'
AND INDEX_NAME = 'idx_nodes_tags');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `nodes` ADD INDEX `idx_nodes_tags` (`tags`(191))',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nodes'
AND INDEX_NAME = 'idx_nodes_port');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `nodes` ADD INDEX `idx_nodes_port` (`port`)',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- migrate:down
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nodes'
AND INDEX_NAME = 'idx_nodes_port');
SET @sql = IF(@index_exists > 0,
'ALTER TABLE `nodes` DROP INDEX `idx_nodes_port`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nodes'
AND INDEX_NAME = 'idx_nodes_tags');
SET @sql = IF(@index_exists > 0,
'ALTER TABLE `nodes` DROP INDEX `idx_nodes_tags`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nodes'
AND INDEX_NAME = 'idx_nodes_address');
SET @sql = IF(@index_exists > 0,
'ALTER TABLE `nodes` DROP INDEX `idx_nodes_address`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nodes'
AND INDEX_NAME = 'idx_nodes_name');
SET @sql = IF(@index_exists > 0,
'ALTER TABLE `nodes` DROP INDEX `idx_nodes_name`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'servers'
AND INDEX_NAME = 'idx_servers_address');
SET @sql = IF(@index_exists > 0,
'ALTER TABLE `servers` DROP INDEX `idx_servers_address`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'servers'
AND INDEX_NAME = 'idx_servers_name');
SET @sql = IF(@index_exists > 0,
'ALTER TABLE `servers` DROP INDEX `idx_servers_name`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND INDEX_NAME = 'idx_payment_name');
SET @sql = IF(@index_exists > 0,
'ALTER TABLE `payment` DROP INDEX `idx_payment_name`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'coupon'
AND INDEX_NAME = 'idx_coupon_name');
SET @sql = IF(@index_exists > 0,
'ALTER TABLE `coupon` DROP INDEX `idx_coupon_name`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_refer_code');
SET @sql = IF(@index_exists > 0,
'ALTER TABLE `user` DROP INDEX `idx_user_refer_code`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'order'
AND INDEX_NAME = 'idx_order_coupon');
SET @sql = IF(@index_exists > 0,
'ALTER TABLE `order` DROP INDEX `idx_order_coupon`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_exists = (SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'order'
AND INDEX_NAME = 'idx_order_trade_no');
SET @sql = IF(@index_exists > 0,
'ALTER TABLE `order` DROP INDEX `idx_order_trade_no`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,20 @@
-- migrate:up
CREATE TABLE IF NOT EXISTS `server_config_overrides`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`server_id` bigint NOT NULL COMMENT 'Server ID',
`ip_strategy` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'IP strategy override, NULL means inherit',
`dns` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'DNS override, NULL means inherit',
`block` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Block override, NULL means inherit',
`outbound` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Outbound override, NULL means inherit',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
UNIQUE KEY `uni_server_config_overrides_server_id` (`server_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- migrate:down
DROP TABLE IF EXISTS `server_config_overrides`;
+42
View File
@@ -0,0 +1,42 @@
-- migrate:up
SET @column_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND COLUMN_NAME = 'sort'
);
SET @sql = IF(
@column_exists = 0,
'ALTER TABLE `payment` ADD COLUMN `sort` bigint NOT NULL DEFAULT 0 COMMENT ''Sort'' AFTER `fee_amount`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE `payment`
SET `sort` = `id`
WHERE `sort` = 0;
-- migrate:down
SET @column_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'payment'
AND COLUMN_NAME = 'sort'
);
SET @sql = IF(
@column_exists > 0,
'ALTER TABLE `payment` DROP COLUMN `sort`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,10 @@
-- migrate:up
INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
SELECT 'subscribe', 'ShowTutorial', 'true', 'bool', 'Show tutorial section on the user document page', '2025-04-22 14:25:16.639', '2025-04-22 14:25:16.639'
WHERE NOT EXISTS (
SELECT 1 FROM `system` WHERE `category` = 'subscribe' AND `key` = 'ShowTutorial'
);
-- migrate:down
DELETE FROM `system` WHERE `category` = 'subscribe' AND `key` = 'ShowTutorial';
@@ -0,0 +1,9 @@
-- migrate:up
-- MySQL datetime type does not have timezone support.
-- The Go code fix (serverPushStatusLogic.go, serverPushUserTrafficLogic.go)
-- removing .UTC() is sufficient for MySQL environments.
SELECT 1;
-- migrate:down
SELECT 1;
+494
View File
@@ -0,0 +1,494 @@
-- migrate:up
-- 000001_init_schema.up.sql
CREATE TABLE IF NOT EXISTS "ads"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"title" varchar(255) NOT NULL DEFAULT '',
"type" varchar(255) NOT NULL DEFAULT '',
"content" text,
"target_url" varchar(512) DEFAULT '',
"start_time" TIMESTAMP DEFAULT NULL,
"end_time" TIMESTAMP DEFAULT NULL,
"status" SMALLINT DEFAULT '0',
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "announcement"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"title" varchar(255) NOT NULL DEFAULT '',
"content" text,
"show" BOOLEAN NOT NULL DEFAULT false,
"pinned" BOOLEAN NOT NULL DEFAULT false,
"popup" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "application"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(255) NOT NULL DEFAULT '',
"icon" text NOT NULL,
"description" text,
"subscribe_type" varchar(50) NOT NULL DEFAULT '',
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "application_config"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"app_id" bigint NOT NULL DEFAULT '0',
"encryption_key" text,
"encryption_method" varchar(255) DEFAULT NULL,
"domains" text,
"startup_picture" text,
"startup_picture_skip_time" bigint NOT NULL DEFAULT '0',
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "application_version"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"url" varchar(255) NOT NULL DEFAULT '',
"version" varchar(255) NOT NULL DEFAULT '',
"platform" varchar(50) NOT NULL DEFAULT '',
"is_default" BOOLEAN NOT NULL DEFAULT false,
"description" text,
"application_id" bigint DEFAULT NULL,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "application_version_fk_application_application_versions" ON "application_version" ("application_id");
CREATE TABLE IF NOT EXISTS "auth_method"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"method" varchar(255) NOT NULL DEFAULT '',
"config" text NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "uni_auth_method" UNIQUE ("method")
);
CREATE TABLE IF NOT EXISTS "coupon"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(255) NOT NULL DEFAULT '',
"code" varchar(255) NOT NULL DEFAULT '',
"count" bigint NOT NULL DEFAULT '0',
"type" SMALLINT NOT NULL DEFAULT '1',
"discount" bigint NOT NULL DEFAULT '0',
"start_time" bigint NOT NULL DEFAULT '0',
"expire_time" bigint NOT NULL DEFAULT '0',
"user_limit" bigint NOT NULL DEFAULT '0',
"subscribe" varchar(255) NOT NULL DEFAULT '',
"used_count" bigint NOT NULL DEFAULT '0',
"enable" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "uni_coupon_code" UNIQUE ("code")
);
CREATE TABLE IF NOT EXISTS "document"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"title" varchar(255) NOT NULL DEFAULT '',
"content" text,
"tags" varchar(255) NOT NULL DEFAULT '',
"show" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "message_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"type" varchar(50) NOT NULL DEFAULT 'email',
"platform" varchar(50) NOT NULL DEFAULT 'smtp',
"to" text NOT NULL,
"subject" varchar(255) NOT NULL DEFAULT '',
"content" text,
"status" SMALLINT NOT NULL DEFAULT '0',
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "order"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"parent_id" bigint DEFAULT NULL,
"user_id" bigint NOT NULL DEFAULT '0',
"order_no" varchar(255) NOT NULL DEFAULT '',
"type" SMALLINT NOT NULL DEFAULT '1',
"quantity" bigint NOT NULL DEFAULT '1',
"price" bigint NOT NULL DEFAULT '0',
"amount" bigint NOT NULL DEFAULT '0',
"gift_amount" bigint NOT NULL DEFAULT '0',
"discount" bigint NOT NULL DEFAULT '0',
"coupon" varchar(255) DEFAULT NULL,
"coupon_discount" bigint NOT NULL DEFAULT '0',
"commission" bigint NOT NULL DEFAULT '0',
"payment_id" bigint NOT NULL DEFAULT '-1',
"method" varchar(255) NOT NULL DEFAULT '',
"fee_amount" bigint NOT NULL DEFAULT '0',
"trade_no" varchar(255) DEFAULT NULL,
"status" SMALLINT NOT NULL DEFAULT '1',
"subscribe_id" bigint NOT NULL DEFAULT '0',
"subscribe_token" varchar(255) DEFAULT NULL,
"is_new" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "uni_order_order_no" UNIQUE ("order_no")
);
CREATE TABLE IF NOT EXISTS "payment"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(100) NOT NULL DEFAULT '',
"platform" varchar(100) NOT NULL,
"description" text,
"icon" varchar(255) DEFAULT '',
"domain" varchar(255) DEFAULT '',
"config" text NOT NULL,
"fee_mode" SMALLINT NOT NULL DEFAULT '0',
"fee_percent" bigint DEFAULT '0',
"fee_amount" bigint DEFAULT '0',
"enable" BOOLEAN NOT NULL DEFAULT false,
"token" varchar(255) DEFAULT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "uni_payment_token" UNIQUE ("token")
);
CREATE TABLE IF NOT EXISTS "server"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(100) NOT NULL DEFAULT '',
"tags" varchar(128) NOT NULL DEFAULT '',
"country" varchar(128) NOT NULL DEFAULT '',
"city" varchar(128) NOT NULL DEFAULT '',
"latitude" varchar(128) NOT NULL DEFAULT '',
"longitude" varchar(128) NOT NULL DEFAULT '',
"server_addr" varchar(100) NOT NULL DEFAULT '',
"relay_mode" varchar(20) NOT NULL DEFAULT 'none',
"relay_node" text,
"speed_limit" bigint NOT NULL DEFAULT '0',
"traffic_ratio" decimal(4, 2) NOT NULL DEFAULT '0.00',
"group_id" bigint DEFAULT NULL,
"protocol" varchar(20) NOT NULL DEFAULT '',
"config" text,
"enable" SMALLINT NOT NULL DEFAULT '1',
"sort" bigint NOT NULL DEFAULT '0',
"last_reported_at" TIMESTAMP(3) DEFAULT NULL,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "server_idx_group_id" ON "server" ("group_id");
CREATE TABLE IF NOT EXISTS "server_group"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(100) NOT NULL DEFAULT '',
"description" varchar(255) DEFAULT '',
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
-- if "sms" not exist, create it
CREATE TABLE IF NOT EXISTS "sms"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"content" text,
"platform" varchar(64) DEFAULT NULL,
"area_code" varchar(64) DEFAULT NULL,
"telephone" varchar(64) DEFAULT NULL,
"status" SMALLINT DEFAULT '1',
"created_at" timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "subscribe"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(255) NOT NULL DEFAULT '',
"description" text,
"unit_price" bigint NOT NULL DEFAULT '0',
"unit_time" varchar(255) NOT NULL DEFAULT '',
"discount" text,
"replacement" bigint NOT NULL DEFAULT '0',
"inventory" bigint NOT NULL DEFAULT '0',
"traffic" bigint NOT NULL DEFAULT '0',
"speed_limit" bigint NOT NULL DEFAULT '0',
"device_limit" bigint NOT NULL DEFAULT '0',
"quota" bigint NOT NULL DEFAULT '0',
"group_id" bigint DEFAULT NULL,
"server_group" varchar(255) DEFAULT NULL,
"server" varchar(255) DEFAULT NULL,
"show" BOOLEAN NOT NULL DEFAULT false,
"sell" BOOLEAN NOT NULL DEFAULT false,
"sort" bigint NOT NULL DEFAULT '0',
"deduction_ratio" bigint DEFAULT '0',
"allow_deduction" BOOLEAN DEFAULT true,
"reset_cycle" bigint DEFAULT '0',
"renewal_reset" BOOLEAN DEFAULT false,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "subscribe_group"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(255) NOT NULL DEFAULT '',
"description" text,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "subscribe_type"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(50) NOT NULL DEFAULT '',
"mark" varchar(255) NOT NULL DEFAULT '',
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "system"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"category" varchar(100) NOT NULL DEFAULT '',
"key" varchar(100) NOT NULL DEFAULT '',
"value" text NOT NULL,
"type" varchar(50) NOT NULL DEFAULT '',
"desc" text NOT NULL,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "uni_system_key" UNIQUE ("key")
);
CREATE INDEX IF NOT EXISTS "system_index_key" ON "system" ("key");
CREATE TABLE IF NOT EXISTS "ticket"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"title" varchar(255) NOT NULL DEFAULT '',
"description" text,
"user_id" bigint NOT NULL DEFAULT '0',
"status" SMALLINT NOT NULL DEFAULT '1',
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "ticket_follow"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"ticket_id" bigint NOT NULL DEFAULT '0',
"from" varchar(255) NOT NULL DEFAULT '',
"type" SMALLINT NOT NULL DEFAULT '1',
"content" text,
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "traffic_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"server_id" bigint NOT NULL,
"user_id" bigint NOT NULL,
"subscribe_id" bigint NOT NULL,
"download" bigint DEFAULT '0',
"upload" bigint DEFAULT '0',
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "traffic_log_idx_subscribe_id" ON "traffic_log" ("subscribe_id");
CREATE INDEX IF NOT EXISTS "traffic_log_idx_server_id" ON "traffic_log" ("server_id");
CREATE INDEX IF NOT EXISTS "traffic_log_idx_user_id" ON "traffic_log" ("user_id");
CREATE TABLE IF NOT EXISTS "user"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"password" varchar(100) NOT NULL,
"avatar" text,
"balance" bigint DEFAULT '0',
"telegram" bigint DEFAULT NULL,
"refer_code" varchar(20) DEFAULT '',
"referer_id" bigint DEFAULT NULL,
"commission" bigint DEFAULT '0',
"gift_amount" bigint DEFAULT '0',
"enable" BOOLEAN NOT NULL DEFAULT true,
"is_admin" BOOLEAN NOT NULL DEFAULT false,
"valid_email" SMALLINT NOT NULL DEFAULT '0',
"enable_email_notify" SMALLINT NOT NULL DEFAULT '0',
"enable_telegram_notify" SMALLINT NOT NULL DEFAULT '0',
"enable_balance_notify" BOOLEAN NOT NULL DEFAULT false,
"enable_login_notify" BOOLEAN NOT NULL DEFAULT false,
"enable_subscribe_notify" BOOLEAN NOT NULL DEFAULT false,
"enable_trade_notify" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
"deleted_at" TIMESTAMP(3) DEFAULT NULL,
"is_del" BIGINT DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_idx_referer" ON "user" ("referer_id");
CREATE TABLE IF NOT EXISTS "user_auth_methods"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"auth_type" varchar(255) NOT NULL,
"auth_identifier" varchar(255) NOT NULL,
"verified" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "idx_auth_identifier" UNIQUE ("auth_identifier")
);
CREATE INDEX IF NOT EXISTS "user_auth_methods_idx_user_id" ON "user_auth_methods" ("user_id");
CREATE TABLE IF NOT EXISTS "user_balance_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"amount" bigint NOT NULL,
"type" SMALLINT NOT NULL,
"order_id" bigint DEFAULT NULL,
"balance" bigint NOT NULL,
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_balance_log_idx_user_id" ON "user_balance_log" ("user_id");
CREATE TABLE IF NOT EXISTS "user_commission_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"order_no" varchar(191) DEFAULT NULL,
"amount" bigint NOT NULL,
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_commission_log_idx_user_id" ON "user_commission_log" ("user_id");
CREATE TABLE IF NOT EXISTS "user_device"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"subscribe_id" bigint DEFAULT NULL,
"ip" varchar(191) DEFAULT NULL,
"identifier" varchar(191) DEFAULT NULL,
"user_agent" varchar(64) DEFAULT NULL,
"online" BOOLEAN NOT NULL DEFAULT false,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_device_idx_user_id" ON "user_device" ("user_id");
CREATE TABLE IF NOT EXISTS "user_gift_amount_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"user_subscribe_id" bigint DEFAULT NULL,
"order_no" varchar(191) DEFAULT NULL,
"type" SMALLINT NOT NULL,
"amount" bigint NOT NULL,
"balance" bigint NOT NULL,
"remark" varchar(255) DEFAULT '',
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_gift_amount_log_idx_user_id" ON "user_gift_amount_log" ("user_id");
CREATE TABLE IF NOT EXISTS "user_login_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"login_ip" varchar(255) NOT NULL,
"user_agent" text NOT NULL,
"success" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_login_log_idx_user_id" ON "user_login_log" ("user_id");
CREATE TABLE IF NOT EXISTS "user_subscribe"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"order_id" bigint NOT NULL,
"subscribe_id" bigint NOT NULL,
"start_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
"expire_time" TIMESTAMP(3) DEFAULT NULL,
"traffic" bigint DEFAULT '0',
"download" bigint DEFAULT '0',
"upload" bigint DEFAULT '0',
"token" varchar(255) DEFAULT '',
"uuid" varchar(255) DEFAULT '',
"status" SMALLINT DEFAULT '0',
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "uni_user_subscribe_token" UNIQUE ("token"),
CONSTRAINT "uni_user_subscribe_uuid" UNIQUE ("uuid")
);
CREATE INDEX IF NOT EXISTS "user_subscribe_idx_user_id" ON "user_subscribe" ("user_id");
CREATE INDEX IF NOT EXISTS "user_subscribe_idx_order_id" ON "user_subscribe" ("order_id");
CREATE INDEX IF NOT EXISTS "user_subscribe_idx_subscribe_id" ON "user_subscribe" ("subscribe_id");
CREATE INDEX IF NOT EXISTS "user_subscribe_idx_token" ON "user_subscribe" ("token");
CREATE INDEX IF NOT EXISTS "user_subscribe_idx_uuid" ON "user_subscribe" ("uuid");
CREATE TABLE IF NOT EXISTS "user_subscribe_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"user_subscribe_id" bigint NOT NULL,
"token" varchar(255) NOT NULL,
"ip" varchar(255) NOT NULL,
"user_agent" text NOT NULL,
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_subscribe_log_idx_user_id" ON "user_subscribe_log" ("user_id");
CREATE INDEX IF NOT EXISTS "user_subscribe_log_idx_user_subscribe_id" ON "user_subscribe_log" ("user_subscribe_id");
CREATE TABLE IF NOT EXISTS "server_rule_group"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(100) NOT NULL DEFAULT '',
"icon" text,
"tags" text,
"description" varchar(255) DEFAULT '',
"enable" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "unique_name" UNIQUE ("name")
);
-- migrate:down
-- 000001_init_schema.down.sql
DROP TABLE IF EXISTS "user_subscribe_log";
DROP TABLE IF EXISTS "user_subscribe";
DROP TABLE IF EXISTS "user_login_log";
DROP TABLE IF EXISTS "user_gift_amount_log";
DROP TABLE IF EXISTS "user_device";
DROP TABLE IF EXISTS "user_commission_log";
DROP TABLE IF EXISTS "user_balance_log";
DROP TABLE IF EXISTS "user_auth_methods";
DROP TABLE IF EXISTS "user";
DROP TABLE IF EXISTS "traffic_log";
DROP TABLE IF EXISTS "ticket_follow";
DROP TABLE IF EXISTS "ticket";
DROP TABLE IF EXISTS "system";
DROP TABLE IF EXISTS "subscribe_type";
DROP TABLE IF EXISTS "subscribe_group";
DROP TABLE IF EXISTS "subscribe";
DROP TABLE IF EXISTS "sms";
DROP TABLE IF EXISTS "server_rule_group";
DROP TABLE IF EXISTS "server_group";
DROP TABLE IF EXISTS "server";
DROP TABLE IF EXISTS "payment";
DROP TABLE IF EXISTS "order";
DROP TABLE IF EXISTS "message_log";
DROP TABLE IF EXISTS "document";
DROP TABLE IF EXISTS "coupon";
DROP TABLE IF EXISTS "auth_method";
DROP TABLE IF EXISTS "application_version";
DROP TABLE IF EXISTS "application_config";
DROP TABLE IF EXISTS "application";
DROP TABLE IF EXISTS "announcement";
DROP TABLE IF EXISTS "ads";
@@ -0,0 +1,135 @@
-- migrate:up
-- 000002_init_data.up.sql
-- auth_method
INSERT INTO "auth_method" ("id", "method", "config", "enabled", "created_at", "updated_at")
VALUES
(1, 'email', '{"platform":"smtp","platform_config":{"host":"","port":0,"user":"","pass":"","from":"","ssl":false},"enable_verify":false,"enable_notify":false,"enable_domain_suffix":false,"domain_suffix_list":"","verify_email_template":"","expiration_email_template":"","maintenance_email_template":"","traffic_exceed_email_template":""}', true, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'),
(2, 'mobile', '{"platform":"AlibabaCloud","platform_config":{"access":"","secret":"","sign_name":"","endpoint":"","template_code":""},"enable_whitelist":false,"whitelist":[]}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'),
(3, 'apple', '{"team_id":"","key_id":"","client_id":"","client_secret":"","redirect_url":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'),
(4, 'google', '{"client_id":"","client_secret":"","redirect_url":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'),
(5, 'github', '{"client_id":"","client_secret":"","redirect_url":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'),
(6, 'facebook', '{"client_id":"","client_secret":"","redirect_url":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'),
(7, 'telegram', '{"bot_token":"","enable_notify":false,"webhook_domain":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'),
(8, 'device', '{"show_ads":false,"only_real_device":false,"enable_security":false,"security_secret":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642');
-- payment
INSERT INTO "payment" ("id", "name", "platform", "description", "icon", "domain", "config", "fee_mode",
"fee_percent", "fee_amount", "enable", "token")
VALUES
(-1, 'Balance', 'balance', '', '', '', '', 0, 0, 0, true, '');
-- subscribe_type
INSERT INTO "subscribe_type" ("id", "name", "mark", "created_at", "updated_at")
VALUES (1, 'Clash', 'Clash', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(2, 'Hiddify', 'Hiddify', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(3, 'Loon', 'Loon', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(4, 'NekoBox', 'NekoBox', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(5, 'NekoRay', 'NekoRay', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(6, 'Netch', 'Netch', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(7, 'Quantumult', 'Quantumult', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(8, 'Shadowrocket', 'Shadowrocket', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(9, 'SingBox', ' SingBox', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(10, 'Surfboard', 'Surfboard', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(11, 'Surge', 'Surge', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(12, 'V2box', 'V2box', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(13, 'V2rayN', 'V2rayN', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'),
(14, 'V2rayNg', 'V2rayNg', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648');
-- system
INSERT INTO "system" ("id", "category", "key", "value", "type", "desc", "created_at", "updated_at")
VALUES (1, 'site', 'SiteLogo', '/favicon.svg', 'string', 'Site Logo', '2025-04-22 14:25:16.637',
'2025-04-22 14:25:16.637'),
(2, 'site', 'SiteName', 'Perfect Panel', 'string', 'Site Name', '2025-04-22 14:25:16.637',
'2025-04-22 14:25:16.637'),
(3, 'site', 'SiteDesc',
'PPanel is a pure, professional, and perfect open-source proxy panel tool, designed to be your ideal choice for learning and practical use.',
'string', 'Site Description', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
(4, 'site', 'Host', '', 'string', 'Site Host', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
(5, 'site', 'Keywords', 'Perfect Panel,PPanel', 'string', 'Site Keywords', '2025-04-22 14:25:16.637',
'2025-04-22 14:25:16.637'),
(6, 'site', 'CustomHTML', '', 'string', 'Custom HTML', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
(7, 'tos', 'TosContent', 'Welcome to use Perfect Panel', 'string', 'Terms of Service', '2025-04-22 14:25:16.637',
'2025-04-22 14:25:16.637'),
(8, 'tos', 'PrivacyPolicy', '', 'string', 'PrivacyPolicy', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
(9, 'ad', 'WebAD', 'false', 'bool', 'Display ad on the web', '2025-04-22 14:25:16.637',
'2025-04-22 14:25:16.637'),
(10, 'subscribe', 'SingleModel', 'false', 'bool', '是否单订阅模式', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(11, 'subscribe', 'SubscribePath', '/api/subscribe', 'string', '订阅路径', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(12, 'subscribe', 'SubscribeDomain', '', 'string', '订阅域名', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(13, 'subscribe', 'PanDomain', 'false', 'bool', '是否使用泛域名', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(14, 'verify', 'TurnstileSiteKey', '', 'string', 'TurnstileSiteKey', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(15, 'verify', 'TurnstileSecret', '', 'string', 'TurnstileSecret', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(16, 'verify', 'EnableLoginVerify', 'false', 'bool', 'is enable login verify', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(17, 'verify', 'EnableRegisterVerify', 'false', 'bool', 'is enable register verify', '2025-04-22 14:25:16.639',
'2025-04-22 14:25:16.639'),
(18, 'verify', 'EnableResetPasswordVerify', 'false', 'bool', 'is enable reset password verify',
'2025-04-22 14:25:16.639', '2025-04-22 14:25:16.639'),
(19, 'server', 'NodeSecret', '12345678', 'string', 'node secret', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(20, 'server', 'NodePullInterval', '10', 'int', 'node pull interval', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(21, 'server', 'NodePushInterval', '60', 'int', 'node push interval', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(22, 'server', 'NodeMultiplierConfig', '[]', 'string', 'node multiplier config', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(23, 'invite', 'ForcedInvite', 'false', 'bool', 'Forced invite', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(24, 'invite', 'ReferralPercentage', '20', 'int', 'Referral percentage', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(25, 'invite', 'OnlyFirstPurchase', 'false', 'bool', 'Only first purchase', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(26, 'register', 'StopRegister', 'false', 'bool', 'is stop register', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(27, 'register', 'EnableTrial', 'false', 'bool', 'is enable trial', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(28, 'register', 'TrialSubscribe', '', 'int', 'Trial subscription', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(29, 'register', 'TrialTime', '24', 'int', 'Trial time', '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'),
(30, 'register', 'TrialTimeUnit', 'Hour', 'string', 'Trial time unit', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(31, 'register', 'EnableIpRegisterLimit', 'false', 'bool', 'is enable IP register limit',
'2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'),
(32, 'register', 'IpRegisterLimit', '3', 'int', 'IP Register Limit', '2025-04-22 14:25:16.640',
'2025-04-22 14:25:16.640'),
(33, 'register', 'IpRegisterLimitDuration', '64', 'int', 'IP Register Limit Duration (minutes)',
'2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'),
(34, 'currency', 'Currency', 'USD', 'string', 'Currency', '2025-04-22 14:25:16.641', '2025-04-22 14:25:16.641'),
(35, 'currency', 'CurrencySymbol', '$', 'string', 'Currency Symbol', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(36, 'currency', 'CurrencyUnit', 'USD', 'string', 'Currency Unit', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(37, 'currency', 'AccessKey', '', 'string', 'Exchangerate Access Key', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(38, 'verify_code', 'VerifyCodeExpireTime', '300', 'int', 'Verify code expire time', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(39, 'verify_code', 'VerifyCodeLimit', '15', 'int', 'limits of verify code', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(40, 'verify_code', 'VerifyCodeInterval', '60', 'int', 'Interval of verify code', '2025-04-22 14:25:16.641',
'2025-04-22 14:25:16.641'),
(41, 'system', 'Version', '0.2.0(02002)', 'string', 'System Version', '2025-04-22 14:25:16.642',
'2025-04-22 14:25:16.642');
SELECT setval(pg_get_serial_sequence('"auth_method"', 'id'), COALESCE((SELECT MAX("id") FROM "auth_method"), 1), true);
SELECT setval(pg_get_serial_sequence('"subscribe_type"', 'id'), COALESCE((SELECT MAX("id") FROM "subscribe_type"), 1), true);
SELECT setval(pg_get_serial_sequence('"system"', 'id'), COALESCE((SELECT MAX("id") FROM "system"), 1), true);
-- migrate:down
-- 000002_init_data.down.sql
DELETE
FROM "auth_method"
WHERE "id" IN (1, 2, 3, 4, 5, 6, 7, 8);
DELETE
FROM "payment"
WHERE "id" = -1;
DELETE
FROM "subscribe_type"
WHERE "id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14);
DELETE
FROM "system"
WHERE "id" IN
(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41);
@@ -0,0 +1,15 @@
-- migrate:up
-- PostgreSQL version of payment/order compatibility migration.
ALTER TABLE "order" ADD COLUMN IF NOT EXISTS "payment_id" BIGINT NOT NULL DEFAULT -1;
ALTER TABLE "payment" ADD COLUMN IF NOT EXISTS "platform" VARCHAR(100) NOT NULL DEFAULT '';
ALTER TABLE "payment" DROP COLUMN IF EXISTS "mark";
ALTER TABLE "payment" ADD COLUMN IF NOT EXISTS "description" TEXT;
ALTER TABLE "payment" ADD COLUMN IF NOT EXISTS "token" VARCHAR(255) DEFAULT NULL;
-- migrate:down
ALTER TABLE "order" DROP COLUMN IF EXISTS "payment_id";
ALTER TABLE "payment" DROP COLUMN IF EXISTS "platform";
ALTER TABLE "payment" DROP COLUMN IF EXISTS "description";
ALTER TABLE "payment" DROP COLUMN IF EXISTS "token";
ALTER TABLE "payment" ADD COLUMN IF NOT EXISTS "mark" VARCHAR(255) DEFAULT NULL;
@@ -0,0 +1,27 @@
-- migrate:up
-- migrations/02003_rebuild_rule.up.sql
-- Purpose: rebuilding server rule table
-- Author: PPanel Team, 2025-04-21
DROP TABLE IF EXISTS "server_rule_group";
CREATE TABLE "server_rule_group"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" VARCHAR(64) NOT NULL DEFAULT '',
"icon" VARCHAR(255),
"tags" TEXT,
"rules" TEXT,
"enable" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3),
PRIMARY KEY ("id"),
CONSTRAINT "uni_server_rule_group_name" UNIQUE ("name")
);
CREATE INDEX IF NOT EXISTS "server_rule_group_idx_enable" ON "server_rule_group" ("enable");
-- migrate:down
-- migrations/02003_rebuild_rule.up.sql
-- Purpose: Back rebuilding server rule table
-- Author: PPanel Team, 2025-04-21
DROP TABLE IF EXISTS server_rule_group;
@@ -0,0 +1,23 @@
-- migrate:up
CREATE TABLE IF NOT EXISTS "user_device_online_record"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"user_id" BIGINT NOT NULL,
"identifier" VARCHAR(255) NOT NULL,
"online_time" TIMESTAMP,
"offline_time" TIMESTAMP,
"online_seconds" BIGINT,
"duration_days" BIGINT,
"created_at" TIMESTAMP
);
ALTER TABLE "user_subscribe" ADD COLUMN IF NOT EXISTS "finished_at" TIMESTAMP NULL;
ALTER TABLE "application_config" ADD COLUMN IF NOT EXISTS "invitation_link" TEXT NULL DEFAULT NULL;
ALTER TABLE "application_config" ADD COLUMN IF NOT EXISTS "kr_website_id" VARCHAR(255) NULL DEFAULT NULL;
-- migrate:down
DROP TABLE IF EXISTS "user_device_online_record";
ALTER TABLE "user_subscribe" DROP COLUMN IF EXISTS "finished_at";
ALTER TABLE "application_config" DROP COLUMN IF EXISTS "invitation_link";
ALTER TABLE "application_config" DROP COLUMN IF EXISTS "kr_website_id";
@@ -0,0 +1,24 @@
-- migrate:up
-- migrations/02008_create_user_reset_subscribe_log.up.sql
-- Purpose: Create user_reset_subscribe_log table
-- Author: PPanel Team, 2025-04-22
CREATE TABLE IF NOT EXISTS "user_reset_subscribe_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY,
"user_id" BIGINT NOT NULL,
"type" SMALLINT NOT NULL,
"order_no" VARCHAR(255) DEFAULT NULL,
"user_subscribe_id" BIGINT NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "user_reset_subscribe_log_idx_user_id" ON "user_reset_subscribe_log" ("user_id");
CREATE INDEX IF NOT EXISTS "user_reset_subscribe_log_idx_user_subscribe_id" ON "user_reset_subscribe_log" ("user_subscribe_id");
-- migrate:down
-- migrations/02008_create_user_reset_subscribe_log.down.sql
-- Purpose: Drop user_reset_subscribe_log table
-- Author: PPanel Team, 2025-04-22
DROP TABLE IF EXISTS "user_reset_subscribe_log";
+10
View File
@@ -0,0 +1,10 @@
-- migrate:up
ALTER TABLE "server_rule_group"
ADD COLUMN "default" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "type" VARCHAR(100) NOT NULL DEFAULT '';
-- migrate:down
ALTER TABLE "server_rule_group"
DROP COLUMN "default",
DROP COLUMN "type";
+26
View File
@@ -0,0 +1,26 @@
-- migrate:up
DROP TABLE IF EXISTS "email_task";
CREATE TABLE "email_task" (
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"subject" varchar(255) NOT NULL,
"content" text NOT NULL,
"recipient" text NOT NULL,
"scope" varchar(50) NOT NULL,
"register_start_time" TIMESTAMP(3) DEFAULT NULL,
"register_end_time" TIMESTAMP(3) DEFAULT NULL,
"additional" text,
"scheduled" TIMESTAMP(3) NOT NULL,
"interval" SMALLINT NOT NULL,
"limit" BIGINT NOT NULL,
"status" SMALLINT NOT NULL,
"errors" text NOT NULL,
"total" BIGINT NOT NULL DEFAULT '0',
"current" BIGINT NOT NULL DEFAULT '0',
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
-- migrate:down
DROP TABLE IF EXISTS "email_task";
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
-- migrate:up
INSERT INTO "system" ("id", "category", "key", "value", "type", "desc", "created_at", "updated_at")
VALUES
(42, 'subscribe', 'UserAgentLimit', 'false', 'bool', 'User Agent Limit', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
(43, 'subscribe', 'UserAgentList', '', 'string', 'User Agent List', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637') ON CONFLICT DO NOTHING;
SELECT setval(pg_get_serial_sequence('"system"', 'id'), COALESCE((SELECT MAX("id") FROM "system"), 1), true);
-- migrate:down
@@ -0,0 +1,7 @@
-- migrate:up
DROP TABLE IF EXISTS "application";
DROP TABLE IF EXISTS "application_version";
DROP TABLE IF EXISTS "application_config";
-- migrate:down
+108
View File
@@ -0,0 +1,108 @@
-- migrate:up
DROP TABLE IF EXISTS "user_balance_log";
DROP TABLE IF EXISTS "user_commission_log";
DROP TABLE IF EXISTS "user_gift_amount_log";
DROP TABLE IF EXISTS "user_login_log";
DROP TABLE IF EXISTS "user_reset_subscribe_log";
DROP TABLE IF EXISTS "user_subscribe_log";
DROP TABLE IF EXISTS "message_log";
DROP TABLE IF EXISTS "system_logs";
CREATE TABLE "system_logs" (
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"type" SMALLINT NOT NULL DEFAULT '0',
"date" varchar(20) DEFAULT NULL,
"object_id" bigint NOT NULL DEFAULT '0',
"content" text NOT NULL,
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "system_logs_idx_type" ON "system_logs" ("type");
CREATE INDEX IF NOT EXISTS "system_logs_idx_object_id" ON "system_logs" ("object_id");
-- migrate:down
CREATE TABLE IF NOT EXISTS "user_balance_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"amount" bigint NOT NULL,
"type" SMALLINT NOT NULL,
"order_id" bigint DEFAULT NULL,
"balance" bigint NOT NULL,
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_balance_log_idx_user_id" ON "user_balance_log" ("user_id");
CREATE TABLE IF NOT EXISTS "user_commission_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"order_no" varchar(191) DEFAULT NULL,
"amount" bigint NOT NULL,
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_commission_log_idx_user_id" ON "user_commission_log" ("user_id");
CREATE TABLE IF NOT EXISTS "user_gift_amount_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"user_subscribe_id" bigint DEFAULT NULL,
"order_no" varchar(191) DEFAULT NULL,
"type" SMALLINT NOT NULL,
"amount" bigint NOT NULL,
"balance" bigint NOT NULL,
"remark" varchar(255) DEFAULT '',
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_gift_amount_log_idx_user_id" ON "user_gift_amount_log" ("user_id");
CREATE TABLE IF NOT EXISTS "user_login_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"login_ip" varchar(255) NOT NULL,
"user_agent" text NOT NULL,
"success" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_login_log_idx_user_id" ON "user_login_log" ("user_id");
CREATE TABLE IF NOT EXISTS "user_reset_subscribe_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY,
"user_id" BIGINT NOT NULL,
"type" SMALLINT NOT NULL,
"order_no" VARCHAR(255) DEFAULT NULL,
"user_subscribe_id" BIGINT NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "user_reset_subscribe_log_idx_user_id" ON "user_reset_subscribe_log" ("user_id");
CREATE INDEX IF NOT EXISTS "user_reset_subscribe_log_idx_user_subscribe_id" ON "user_reset_subscribe_log" ("user_subscribe_id");
CREATE TABLE IF NOT EXISTS "user_subscribe_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"user_subscribe_id" bigint NOT NULL,
"token" varchar(255) NOT NULL,
"ip" varchar(255) NOT NULL,
"user_agent" text NOT NULL,
"created_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "user_subscribe_log_idx_user_id" ON "user_subscribe_log" ("user_id");
CREATE INDEX IF NOT EXISTS "user_subscribe_log_idx_user_subscribe_id" ON "user_subscribe_log" ("user_subscribe_id");
CREATE TABLE IF NOT EXISTS "message_log"
(
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"type" varchar(50) NOT NULL DEFAULT 'email',
"platform" varchar(50) NOT NULL DEFAULT 'smtp',
"to" text NOT NULL,
"subject" varchar(255) NOT NULL DEFAULT '',
"content" text,
"status" SMALLINT NOT NULL DEFAULT '0',
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
DROP TABLE IF EXISTS "system_logs";
+33
View File
@@ -0,0 +1,33 @@
-- migrate:up
CREATE TABLE IF NOT EXISTS "servers" (
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(100) NOT NULL DEFAULT '',
"country" varchar(128) NOT NULL DEFAULT '',
"city" varchar(128) NOT NULL DEFAULT '',
"ratio" decimal(4,2) NOT NULL DEFAULT '0.00',
"address" varchar(100) NOT NULL DEFAULT '',
"sort" bigint NOT NULL DEFAULT '0',
"protocols" text,
"last_reported_at" TIMESTAMP(3) DEFAULT NULL,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "nodes" (
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(100) NOT NULL DEFAULT '',
"tags" varchar(255) NOT NULL DEFAULT '',
"port" INTEGER NOT NULL DEFAULT '0',
"address" varchar(255) NOT NULL DEFAULT '',
"server_id" bigint NOT NULL DEFAULT '0',
"protocol" varchar(100) NOT NULL DEFAULT '',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) DEFAULT NULL,
"updated_at" TIMESTAMP(3) DEFAULT NULL,
PRIMARY KEY ("id")
);
-- migrate:down
DROP TABLE IF EXISTS "nodes";
DROP TABLE IF EXISTS "servers";
+15
View File
@@ -0,0 +1,15 @@
-- migrate:up
ALTER TABLE "subscribe"
ADD COLUMN "nodes" VARCHAR(255) NOT NULL DEFAULT '' ,
ADD COLUMN "node_tags" VARCHAR(255) NOT NULL DEFAULT '' ,
DROP COLUMN "server",
DROP COLUMN "server_group";
DROP TABLE IF EXISTS "server_rule_group";
-- migrate:down
ALTER TABLE "subscribe"
DROP COLUMN "nodes",
DROP COLUMN "node_tags",
ADD COLUMN "server" VARCHAR(255) NOT NULL DEFAULT '' ,
ADD COLUMN "server_group" VARCHAR(255) NOT NULL DEFAULT '';
@@ -0,0 +1,8 @@
-- migrate:up
INSERT INTO "system" ("category", "key", "value", "type", "desc", "created_at", "updated_at")
VALUES
('log', 'AutoClear', 'true', 'bool', 'Auto Clear Log', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'),
('log', 'ClearDays', '7', 'int', 'Clear Days', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637') ON CONFLICT DO NOTHING;
-- migrate:down

Some files were not shown because too many files have changed in this diff Show More