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, pub plan: Plan, pub period_start: DateTime, pub period_end: DateTime, } #[derive(Debug, FromRow)] struct SubscriptionRow { id: Uuid, status: String, current_period_start: DateTime, current_period_end: DateTime, 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 { 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) -> (DateTime, DateTime) { 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)) }