136 lines
4.0 KiB
Rust
136 lines
4.0 KiB
Rust
use crate::error::{AppError, ErrorCode};
|
|
use crate::state::AppState;
|
|
|
|
use chrono::{DateTime, Datelike, TimeZone, Utc};
|
|
use sqlx::FromRow;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Plan {
|
|
pub id: Uuid,
|
|
pub code: String,
|
|
pub included_units_per_period: i32,
|
|
pub max_file_size_mb: i32,
|
|
pub max_files_per_batch: i32,
|
|
pub retention_days: i32,
|
|
pub feature_api_enabled: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct BillingContext {
|
|
pub user_id: Uuid,
|
|
pub subscription_id: Option<Uuid>,
|
|
pub plan: Plan,
|
|
pub period_start: DateTime<Utc>,
|
|
pub period_end: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, FromRow)]
|
|
struct SubscriptionRow {
|
|
id: Uuid,
|
|
status: String,
|
|
current_period_start: DateTime<Utc>,
|
|
current_period_end: DateTime<Utc>,
|
|
plan_id: Uuid,
|
|
}
|
|
|
|
#[derive(Debug, FromRow)]
|
|
struct PlanRow {
|
|
id: Uuid,
|
|
code: String,
|
|
included_units_per_period: i32,
|
|
max_file_size_mb: i32,
|
|
max_files_per_batch: i32,
|
|
retention_days: i32,
|
|
features: serde_json::Value,
|
|
}
|
|
|
|
pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result<BillingContext, AppError> {
|
|
let subscription = sqlx::query_as::<_, SubscriptionRow>(
|
|
r#"
|
|
SELECT id, status::text AS status, current_period_start, current_period_end, plan_id
|
|
FROM subscriptions
|
|
WHERE user_id = $1
|
|
AND status IN ('active', 'trialing', 'past_due')
|
|
ORDER BY current_period_end DESC
|
|
LIMIT 1
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?;
|
|
|
|
let (subscription_id, period_start, period_end, plan_id) = if let Some(sub) = subscription {
|
|
if sub.status == "past_due" {
|
|
return Err(AppError::new(
|
|
ErrorCode::Forbidden,
|
|
"订阅欠费,请先完成支付",
|
|
));
|
|
}
|
|
(Some(sub.id), sub.current_period_start, sub.current_period_end, sub.plan_id)
|
|
} else {
|
|
let plan_id: Uuid = sqlx::query_scalar("SELECT id FROM plans WHERE code = 'free' LIMIT 1")
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "未找到 Free 套餐").with_source(err))?;
|
|
let (start, end) = current_month_period_utc8(Utc::now());
|
|
(None, start, end, plan_id)
|
|
};
|
|
|
|
let plan_row = sqlx::query_as::<_, PlanRow>(
|
|
r#"
|
|
SELECT id, code, included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days, features
|
|
FROM plans
|
|
WHERE id = $1
|
|
"#,
|
|
)
|
|
.bind(plan_id)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?;
|
|
|
|
let feature_api_enabled = plan_row
|
|
.features
|
|
.get("api")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
Ok(BillingContext {
|
|
user_id,
|
|
subscription_id,
|
|
plan: Plan {
|
|
id: plan_row.id,
|
|
code: plan_row.code,
|
|
included_units_per_period: plan_row.included_units_per_period,
|
|
max_file_size_mb: plan_row.max_file_size_mb,
|
|
max_files_per_batch: plan_row.max_files_per_batch,
|
|
retention_days: plan_row.retention_days,
|
|
feature_api_enabled,
|
|
},
|
|
period_start,
|
|
period_end,
|
|
})
|
|
}
|
|
|
|
pub fn current_month_period_utc8(now_utc: DateTime<Utc>) -> (DateTime<Utc>, DateTime<Utc>) {
|
|
let tz = chrono::FixedOffset::east_opt(8 * 3600).unwrap();
|
|
let now = now_utc.with_timezone(&tz);
|
|
let year = now.year();
|
|
let month = now.month();
|
|
|
|
let start = tz.with_ymd_and_hms(year, month, 1, 0, 0, 0).single().unwrap();
|
|
|
|
let (next_year, next_month) = if month == 12 {
|
|
(year + 1, 1)
|
|
} else {
|
|
(year, month + 1)
|
|
};
|
|
let end = tz
|
|
.with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0)
|
|
.single()
|
|
.unwrap();
|
|
|
|
(start.with_timezone(&Utc), end.with_timezone(&Utc))
|
|
}
|