发布于 2026-01-06 3 阅读
0

一个带有 Loco 的全栈 SaaS 模板

一个带有 Loco 的全栈 SaaS 模板

Loco 是一个 Rust 框架,旨在实现所有功能——身份验证、任务管理、迁移等等。虽然它最初使用起来比纯 Axum 更复杂,但它可以节省大量设置样板代码的时间,并提供了一个优秀的构建平台。

在本指南中,我们将通过一个包含 SaaS 订阅支付功能的全栈模板,在 Shuttle 上部署 Loco。利用 Loco 的 SaaS 入门套件,我们可以借助其预构建的身份验证功能,快速轻松地构建支付系统。有兴趣部署或试用最终代码库吗?您可以点击此处查看。

从克隆部署的步骤:

  • 运行cargo shuttle init --from joshua-mo-143/shuttle-stripe-ex并按照提示操作(需要已cargo-shuttle安装)
  • 设置 API 密钥(见下文)
  • 使用cargo shuttle deploy --allow-dirty后,见证奇迹发生!

先决条件

开始之前,您需要一个 Stripe API 密钥。注册 Stripe 是免费的,您可以在正式上线之前启用测试模式。如果您需要帮助,Stripe 也提供了相关文档,您可以在这里查看。

项目创建完成后,请确保Secrets.toml在项目根目录下创建一个文件,并按如下方式添加:

STRIPE_API_KEY = "<YOUR-STRIPE-KEY-HERE>"
Enter fullscreen mode Exit fullscreen mode

入门

我们将使用以下命令初始化项目(需要cargo-shuttle安装),并按照提示初始化一个以我们名称命名的项目。该--from标志允许我们从指定位置获取初始项目。

cargo shuttle init --from loco-rs/loco --subfolder starters/saas
Enter fullscreen mode Exit fullscreen mode

然后我们将使用它cargo loco generate deployment为我们的 Loco 项目生成 Shuttle 部署!

趁此机会,请确保您cargo-shuttle的项目中使用的是最新版本的 Shuttle 及其依赖项。我们今天发布了 v0.40.0 版本!

我们还将通过 shell 代码片段添加以下依赖项:

cargo add async-stripe@0.34.1 -F runtime-tokio-hyper-rustls
cargo add shuttle-shared-db -F postgres
cargo add shuttle-secrets
Enter fullscreen mode Exit fullscreen mode

请注意,您将获得一个空白的 Shuttle 部署,其中未安装数据库。为了解决这个问题,我们将调整主函数以提供我们的数据库注解,并确保Migrator添加(来自迁移文件夹的)数据库注解:

use migrations::Migrator;

#[shuttle_runtime::main]
async fn main(
    #[shuttle_shared_db::Postgres] conn_str: String,
    #[shuttle_metadata::ShuttleMetadata] meta: shuttle_metadata::Metadata,
    #[shuttle_secrets::Secrets] secrets: shuttle_secrets::SecretStore
) -> shuttle_axum::ShuttleAxum {
    std::env::set_var("DATABASE_URL", conn_str);

    let stripe_api_key = secrets
        .get("STRIPE_API_KEY")
        .expect("STRIPE_API_KEY not found in secrets");
    std::env::set_var("STRIPE_API_KEY", stripe_api_key);

    let environment = match meta.env {
        shuttle_metadata::Environment::Local => Environment::Development,
        shuttle_metadata::Environment::Deployment => Environment::Production,
    };
    let boot_result = create_app::<App, Migrator>(StartMode::ServerOnly, &environment)
        .await
        .unwrap();

    let router = boot_result.router.unwrap();
    Ok(router.into())
}
Enter fullscreen mode Exit fullscreen mode

在常规情况下src/bin/main.rs,您可能还需要调整应用程序,以便Migrator将其也包含在内。

完成后,您就可以使用了cargo shuttle run,它应该会自动运行!您会得到一个数据库连接 URL(请保存以备后用!)。

迁徙

首先,我们将进行一些迁移,以便稍后在程序中引用。您可以按如下方式操作:

cargo loco generate model --migration-only subscription_tiers tier:string! stripe_item_id:string! stripe_price_id:string!
cargo loco generate model --migration-only user_subscriptions user:references stripe_customer_id:string! stripe_subscription_id:string! user_tier:string!
Enter fullscreen mode Exit fullscreen mode

然后我们将使用以下工具迁移您的数据库并生成实体:

DATABASE_URL=<DB_URL_HERE> cargo loco db migrate
DATABASE_URL=<DB_URL_HERE> cargo loco db entities
Enter fullscreen mode Exit fullscreen mode

请注意,您需要数据库 URL。如果您还没有数据库 URL,可以使用cargo shuttle run提供的连接字符串自动启动一个 Postgres 容器,或者启动您自己的 Docker 容器。

这两个命令将在migrations文件夹和 中生成一些文件src/models,我们将大量使用这些文件,因为它们是使用 Loco 与数据库交互的主要方式。

前端

本教程不会涵盖前端部分,因为实现方式多种多样——不过,如果您想了解我们的实现方式,欢迎访问此处的代码仓库!我们使用 Loco 提供的 React 框架进行react-router-dom路由和zustand状态管理,并搭配原生 CSS。

仓库中已提供以下页面:

  • 首页
  • 登录和注册页面
  • 一个仪表盘页面,允许用户降级/升级订阅级别、取消订阅以及查看自己当前的级别。
  • 定价和支付结算页面
  • 支付成功/失败页面

错误处理

Loco 默认使用某种机制anyhow来提供简便的错误处理。但是,为了方便起见,我们来创建自己的错误类型。这样做可以实现以下几点:

  • 我们确切地知道发生了什么错误以及错误发生在哪里。
  • 我们可以自定义错误处理的行为。

我们先从之前添加的 crate开始thiserror,添加用于自动实现std::fmt::DIsplay`and` 的宏std::error::Error。`derive`thiserror::Error宏还允许我们向结构体添加属性宏以实现自动From<T>实现,这节省了大量时间!当然,From<T>如果您想根据错误原因创建多个枚举变体,也可以手动实现。

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("Stripe error: {0}")]
    Stripe(#[from] stripe::StripeError),
    #[error("User already has this subscription tier!")]
    UserTierAlreadyExists,
    #[error("SQL error: {0}")]
    SQL(#[from] sea_orm::DbErr),
    #[error("Model error: {0}")]
    Model(#[from] loco_rs::model::ModelError),
}
Enter fullscreen mode Exit fullscreen mode

为了能在我们的 API 中使用此功能,我们需要进行实现axum::response::IntoResponse。我们可以简单地匹配每个枚举变体,如下所示:

use axum::{http::StatusCode, response::{Response, IntoResponse}};

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let res = match self {
            Self::Stripe(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
            Self::SQL(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
            Self::UserTierAlreadyExists => (
                StatusCode::BAD_REQUEST,
                "User already has this tier!".to_string(),
            ),
            Self::Model(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
        };
        res.into_response()
    }
}
Enter fullscreen mode Exit fullscreen mode

使用 Stripe

要开始使用 Stripe,我们需要创建一个新的 loco 控制器文件来存放所有路由。我们可以使用 ` stripe.routes.conf`命令来完成此操作cargo loco generate controller stripe该命令会在 `/etc/routes/routes/local ...src/controllers/stripe.rssrc/app.rs

查看内部src/controllers/stripe.rs应该会得到一个返回Routes(基于axum::Router)的结构体的函数,以及几个用于返回“Hello, world!”和给定请求内容的路由。

首先,我们来定义一下用户等级。假设我们有专业版等级和团队版等级。我们可以编写一个枚举及其相关的实现,如下所示:

use std::fmt;
use serde::{Deserialize, Serialize};
use sea_orm::{EnumIter, DeriveActiveEnum};

#[derive(EnumIter, DeriveActiveEnum, Clone, Deserialize, Debug, Serialize, PartialEq, Eq)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(1))")]
pub enum UserTier {
    #[sea_orm(string_value = "P")]
    Pro,
    #[sea_orm(string_value = "T")]
    Team,
}

impl UserTier {
    fn get_price(&self) -> Option<i64> {
        match self {
            Self::Pro => Some(1000),
            Self::Team => Some(2500),
        }
    }

    fn from_str(str: &str) -> Self {
        match str {
            "Pro" => Self::Pro,
            "Team" => Self::Team,
            _ => panic!("There should only be the Pro and Team tier!")
        }
    }
}

impl fmt::Display for UserTier {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Pro => write!(f, "Pro"),
            Self::Team => write!(f, "Team"),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

请注意,我们导入的宏sea_orm将允许枚举作为类型存储varchar在 Postgres 中。

创建 Stripe 产品和价格

在进行任何其他操作之前,我们需要创建一个带有价格的 Stripe 产品。为此,我们可以创建两个函数:一个用于创建产品项,另一个用于添加价格(因为 Stripe 上的产品可以有多个价格)。创建价格需要先创建一个产品。为了简单起见,我们每个产品项只关联一个价格。这两个函数都将在其他函数中使用。

use stripe::{Client, Product, Price, CreatePrice, CreateProduct, IdOrCreate, 
CreatePriceRecurring, CreatePriceRecurringInterval, Currency};

async fn create_product_item(client: &Client, user_tier: &UserTier) -> Result<Product, ApiError> {
    let product = {
        let mut create_product = match user_tier {
            UserTier::Pro => CreateProduct::new("Pro User Subscription"),
            UserTier::Team => CreateProduct::new("Team Subscription"),
        };

        create_product.metadata = Some(std::collections::HashMap::from([(
            String::from("async-stripe"),
            String::from("true"),
        )]));

        Product::create(client, create_product).await?
    };

    Ok(product)
}

async fn create_product_price(
    client: &Client,
    user_tier: &UserTier,
    product: &Product,
) -> Result<Price, ApiError> {
    let price = {
        let mut create_price = CreatePrice::new(Currency::USD);
        create_price.product = Some(IdOrCreate::Id(&product.id));
        create_price.metadata = Some(std::collections::HashMap::from([(
            String::from("async-stripe"),
            String::from("true"),
        )]));
        create_price.unit_amount = user_tier.get_price();

        create_price.recurring = Some(CreatePriceRecurring {
            interval: CreatePriceRecurringInterval::Month,
            ..Default::default()
        });
        create_price.expand = &["product"];
        Price::create(client, create_price).await?
    };

    Ok(price)
}
Enter fullscreen mode Exit fullscreen mode

这样一来,我们就可以构建一个更高级的函数,该函数可以直接检索数据库中已存在的 Stripe 产品 ID,或者创建一个包含价格的产品,并将详细信息保存到数据库中。为了简化操作(并且不想处理数据库中的财务信息安全问题),我们只会存储产品 ID 和相关信息,例如用户所属的产品级别。

use sea_orm::DatabaseConnection;

async fn retrieve_product(
    client: &Client,
    user_tier: &UserTier,
    db: &DatabaseConnection,
) -> Result<Price, ApiError> {
    use crate::models::_entities::subscription_tiers;

    let product = subscription_tiers::Entity::find()
        .filter(subscription_tiers::Column::Tier.contains(user_tier.to_string()))
        .one(db)
        .await?;

    let price = match product {
        Some(product) => {
            Price::retrieve(
                client,
                &PriceId::from_str(&product.stripe_price_id.to_string()).unwrap(),
                &["product"],
            )
            .await?
        }
        None => {
            let product = create_product_item(client, &user_tier).await?;
            let price = create_product_price(client, &user_tier, &product).await?;

            let tier_model = subscription_tiers::ActiveModel {
                tier: ActiveValue::Set(user_tier.to_string()),
                stripe_item_id: ActiveValue::Set(product.id.to_string()),
                stripe_price_id: ActiveValue::Set(price.id.to_string()),
                ..Default::default()
            };

            subscription_tiers::Entity::insert(tier_model)
                .exec(db)
                .await?;

            price
        }
    };

    Ok(price)
}
Enter fullscreen mode Exit fullscreen mode

之后,当我们运行 Web 服务时,API 应该能够自动知道是否需要重新创建 Stripe 产品。

让我们来编写一个创建订阅的函数。首先,我们需要定义 API 收到请求时 JSON 输入应该是什么样子:

#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct UserSubscription {
    name: String,
    email: String,
    card_num: String,
    exp_year: i32,
    exp_month: i32,
    cvc: String,
    user_tier: UserTier,
}
Enter fullscreen mode Exit fullscreen mode

请注意,发送请求时,变量必须采用驼峰式命名法,而不是蛇形命名法。

为了方便我们以后的工作,我们将添加一个implfor 循环UserSubscription,这将使我们能够自动将其转换为一些结构体,我们稍后将使用这些结构体来创建客户和CardDetailsParams

impl UserSubscription {
    fn as_create_customer(&self) -> CreateCustomer {
        CreateCustomer {
            name: Some(&self.name),
            email: Some(&self.email),
            description: Some(
                "A paying user.",
            ),
            metadata: Some(std::collections::HashMap::from([(
                String::from("async-stripe"),
                String::from("true"),
            )])),

            ..Default::default()
        }
    }

    fn as_card_details_params(&self) -> CardDetailsParams {
        CardDetailsParams {
            number: self.card_num.to_string(),
            exp_year: self.exp_year,
            exp_month: self.exp_month,
            cvc: Some(self.cvc.clone()),
            ..Default::default()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

创建订阅

接下来,我们要开始编写用于创建用户订阅的端点了!我们将stripe::Client使用之前存储的 API 密钥在这里创建一个端点。

use crate::models::_entities::user_subscriptions;
use crate::models::_entities::users;
use loco_rs::controller::middleware::auth::JWTWithUser;

pub async fn create_subscription(
    State(ctx): State<AppContext>,
    auth: middleware::auth::JWTWithUser<users::Model>,
    Json(json): Json<UserSubscription>,
) -> Result<StatusCode, ApiError> {
    let secret_key = std::env::var("STRIPE_API_KEY").expect("Missing STRIPE_API_KEY in env");
    let client = Client::new(secret_key);

    // .. rest of your code
}
Enter fullscreen mode Exit fullscreen mode

这里需要注意的是,我们专门使用JWTWithUser中间件从 Authorization 标头中提取 Bearer JWT 并返回用户模型。

接下来,我们要创建一个客户并将其添加到我们的 Stripe 账户。我们还会创建支付方式并将其关联到该客户。请注意,虽然我们这里使用的是银行卡支付,因为它是一种非常常见的支付方式,但 Stripe 也提供许多其他类型的支付方式供您选择。

use stripe::{Customer, PaymentMethod, PaymentMethodTypeFilter, 
CreatePaymentMethodCardUnion, AttachPaymentMethod};

let customer = Customer::create(&client, json.as_create_customer()).await?;

let payment_method = {
    let pm = PaymentMethod::create(&client, CreatePaymentMethod {
        type_: Some(PaymentMethodTypeFilter::Card),
        card: Some(CreatePaymentMethodCardUnion::CardDetailsParams(json.as_card_details_params())),
        ..Default::default()
    }).await?;

    PaymentMethod::attach(&client, &pm.id, AttachPaymentMethod {
        customer: customer.id.clone(),
    }).await?;

    pm
};
Enter fullscreen mode Exit fullscreen mode

接下来,我们需要添加创建订阅的部分。这部分相对简单,因为我们的订阅列表中只需要一个项目——即 SaaS 订阅的基本价格(当然,如果您想延长订阅期限,也可以添加更多项目!):

use stripe::{CreateSubscription, CreateSubscriptionItems, Subscription,

let mut params = CreateSubscription::new(customer.id.clone());
params.items = Some(
    vec![CreateSubscriptionItems {
        price: Some(price.id.to_string()),
        ..Default::default()
    }]
);

params.default_payment_method = Some(&payment_method.id);
params.expand = &["items", "items.data.price.product", "schedule"];

let subscription = Subscription::create(&client, params).await?;

let subscription_activemodel = user_subscriptions::ActiveModel {
    user_id: ActiveValue::Set(auth.user.id),
    stripe_customer_id: ActiveValue::Set(customer.id.to_string()),
    stripe_subscription_id: ActiveValue::Set(subscription.id.to_string()),
    user_tier: ActiveValue::Set(json.user_tier),
    ..Default::default()
};

user_subscriptions::Entity::insert(subscription_activemodel).exec(&ctx.db).await?;

Ok(StatusCode::OK);
Enter fullscreen mode Exit fullscreen mode

如果在编写此函数时遇到错误,可以在此处的仓库中找到该函数。

取消 Stripe 订阅

好的,现在假设您希望用户能够自助取消 SaaS 订阅。这主要涉及取消订阅,然后确保更新数据库。在这种情况下,我们选择在成功取消后直接从数据库中删除记录。

首先,我们将通过获取 API 密钥来重新创建 Stripe 客户端:

pub async fn cancel_subscription(
    State(ctx): State<AppContext>,
    auth: middleware::auth::JWTWithUser<UsersModel>,
) -> Result<StatusCode, ApiError> {
    let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;

    let secret_key = std::env::var("STRIPE_API_KEY").expect("Missing STRIPE_API_KEY in env");
    let client = Client::new(secret_key);

    // .. rest of your code
}
Enter fullscreen mode Exit fullscreen mode

接下来,我们将根据用户 ID 外键从表中查找用户订阅信息user_subscriptions。然后,我们将使用 Stripe 订阅 ID 取消订阅。

let subscription = user_subscriptions::Entity
    ::find()
    .filter(user_subscriptions::Column::UserId.eq(user.id))
    .one(&ctx.db).await?
    .unwrap();

let _ = Subscription::cancel(
    &client,
    &SubscriptionId::from_str(&subscription.stripe_subscription_id).unwrap(),
    CancelSubscription {
        cancellation_details: None,
        invoice_now: Some(true),
        prorate: Some(true),
    }
).await?;
Enter fullscreen mode Exit fullscreen mode

订阅取消成功且我们无需再对 Stripe 进行任何操作后,我们可以返回并更新数据库以删除该记录:

let subscription_to_delete = user_subscriptions::Entity
    ::find()
    .filter(user_subscriptions::Column::UserId.eq(user.id))
    .one(&ctx.db).await?
    .unwrap();

subscription_to_delete.delete(&ctx.db).await?;

Ok(StatusCode::OK);
Enter fullscreen mode Exit fullscreen mode

请注意,处理这种情况有多种不同的方法。如果您希望用户能够查看其订阅历史记录和其他此类详细信息,则可能需要将客户的订阅明确标记为“已过期”,而不是直接从数据库中删除。

订阅等级升级/降级

最后,我们将添加升级和降级订阅级别的功能。用 Stripe 的术语来说,我们会获取用户的订阅信息,并更新订阅中现有商品的价格 ID。

和以前一样,我们先从创建stripe::Client

pub async fn update_subscription_tier(
    State(ctx): State<AppContext>,
    auth: middleware::auth::ApiToken<UsersModel>,
    Json(new_user_tier): Json<UpdateUserTier>,
) -> Result<StatusCode, ApiError> {
    use crate::models::_entities::subscription_tiers;
    use crate::models::_entities::user_subscriptions;

    let secret_key = std::env::var("STRIPE_API_KEY").expect("Missing STRIPE_API_KEY in env");
    let client = Client::new(secret_key);
    // .. rest of your code
}
Enter fullscreen mode Exit fullscreen mode

之后,我们将user_subscription根据用户 ID 查找订阅者。然后,我们将从 Stripe 获取订阅数据,并获取订阅列表中的第一个项目。请注意,虽然我们使用的是向量索引,理论上可能会引发 panic,但列表中应该始终至少有一个项目,因此使用索引 0 是安全的。如果用户的当前层级与请求的层级相同,我们也会返回错误。

let user_subscription = user_subscriptions::Entity
    ::find()
    .filter(user_subscriptions::Column::UserId.eq(auth.user.id))
    .one(&ctx.db).await?
    .unwrap();

let subscription_item = Subscription::retrieve(
    &client,
    &SubscriptionId::from_str(&user_subscription.stripe_subscription_id).unwrap(),
    &["items"]
).await?.items;

let subscription_item = &subscription_item.data[0];

if new_user_tier.user_tier == user_subscription.user_tier {
    return Err(ApiError::UserTierAlreadyExists);
}
Enter fullscreen mode Exit fullscreen mode

完成后,我们可以根据请求的层级更改从数据库中查找订阅层级数据,并使用新层的 Stripe 价格对象 ID 更新订阅。

let new_subscription = subscription_tiers::Entity
    ::find()
    .filter(subscription_tiers::Column::Tier.contains(new_user_tier.user_tier.to_string()))
    .all(&ctx.db).await?;

let new_sub_tier: String = new_subscription
    .iter()
    .find(|x| x.tier == new_user_tier.user_tier.to_string())
    .map(|x| x.stripe_price_id.to_string())
    .unwrap();

let updated_subscription = Subscription::update(
    &client,
    &SubscriptionId::from_str(&user_subscription.stripe_subscription_id).unwrap(),
    UpdateSubscription {
        items: Some(
            vec![UpdateSubscriptionItems {
                id: Some(subscription_item.id.to_string()),
                price: Some(new_sub_tier),
                ..Default::default()
            }]
        ),
        ..Default::default()
    }
).await?;
Enter fullscreen mode Exit fullscreen mode

在完成此功能之前,我们需要更新user_subscriptions表中的用户层级。

use sea_orm::IntoActiveModel;

let mut updated_user_subscription = user_subscription.into_active_model();
updated_user_subscription.user_tier = ActiveValue::Set(new_user_tier.user_tier);

let _ = updated_user_subscription.update(&ctx.db).await?;

Ok(StatusCode::OK);
Enter fullscreen mode Exit fullscreen mode

获取用户的产品层级

当然,对于前端来说,你可能需要一种快速获取用户产品层级的方法。你可以这样做:

pub async fn get_current_tier(
    State(ctx): State<AppContext>,
    auth: middleware::auth::JWTWithUser<UsersModel>,
) -> Result<Json<UpdateUserTier>, ApiError> {
    let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;

    let subscription = user_subscriptions::Entity::find()
        .filter(user_subscriptions::Column::UserId.eq(user.id))
        .one(&ctx.db)
        .await?
        .unwrap();

    Ok(Json(UpdateUserTier { user_tier: subscription.user_tier  }))

}
Enter fullscreen mode Exit fullscreen mode

把所有东西都连接起来

现在我们已经完成了,可以把它添加回控制器的路由器文件中。我们可以像这样添加回去:

pub fn routes() -> Routes {
    Routes::new()
        .prefix("stripe")
        .add("/get_current_tier", get(get_current_tier))
        .add("/create", post(create_subscription))
        .add("/update", post(update_subscription_tier))
        .add("/cancel", delete(cancel_subscription))
}
Enter fullscreen mode Exit fullscreen mode

部署

部署非常简单,只需运行cargo shuttle deploy --allow-dirtyShuttle 命令,剩下的就交给它来完成吧!完成后,您将看到有关服务的信息。

即将结束

Loco 是一个非常强大的入门框架,通过实施订阅支付,我们成功地为正在打造的潜在 SaaS 产品拼上了最后一块拼图。

想了解更多?

  • 尝试使用 Qdrant 和 OpenAI 为您的 SaaS 添加基于 RAG 的 LLM
  • 在此处为您的项目添加跟踪功能,以便更好地记录日志
  • 请尝试在此处实现基于 OAuth2 的身份验证
文章来源:https://dev.to/shuttle_dev/a-full-stack-saas-template-with-loco-43ak