一个带有 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>"
入门
我们将使用以下命令初始化项目(需要cargo-shuttle安装),并按照提示初始化一个以我们名称命名的项目。该--from标志允许我们从指定位置获取初始项目。
cargo shuttle init --from loco-rs/loco --subfolder starters/saas
然后我们将使用它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
请注意,您将获得一个空白的 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())
}
在常规情况下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!
然后我们将使用以下工具迁移您的数据库并生成实体:
DATABASE_URL=<DB_URL_HERE> cargo loco db migrate
DATABASE_URL=<DB_URL_HERE> cargo loco db entities
请注意,您需要数据库 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),
}
为了能在我们的 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()
}
}
使用 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"),
}
}
}
请注意,我们导入的宏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)
}
这样一来,我们就可以构建一个更高级的函数,该函数可以直接检索数据库中已存在的 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)
}
之后,当我们运行 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,
}
请注意,发送请求时,变量必须采用驼峰式命名法,而不是蛇形命名法。
为了方便我们以后的工作,我们将添加一个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()
}
}
}
创建订阅
接下来,我们要开始编写用于创建用户订阅的端点了!我们将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
}
这里需要注意的是,我们专门使用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
};
接下来,我们需要添加创建订阅的部分。这部分相对简单,因为我们的订阅列表中只需要一个项目——即 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);
如果在编写此函数时遇到错误,可以在此处的仓库中找到该函数。
取消 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
}
接下来,我们将根据用户 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?;
订阅取消成功且我们无需再对 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);
请注意,处理这种情况有多种不同的方法。如果您希望用户能够查看其订阅历史记录和其他此类详细信息,则可能需要将客户的订阅明确标记为“已过期”,而不是直接从数据库中删除。
订阅等级升级/降级
最后,我们将添加升级和降级订阅级别的功能。用 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
}
之后,我们将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);
}
完成后,我们可以根据请求的层级更改从数据库中查找订阅层级数据,并使用新层的 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?;
在完成此功能之前,我们需要更新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);
获取用户的产品层级
当然,对于前端来说,你可能需要一种快速获取用户产品层级的方法。你可以这样做:
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 }))
}
把所有东西都连接起来
现在我们已经完成了,可以把它添加回控制器的路由器文件中。我们可以像这样添加回去:
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))
}
部署
部署非常简单,只需运行cargo shuttle deploy --allow-dirtyShuttle 命令,剩下的就交给它来完成吧!完成后,您将看到有关服务的信息。
即将结束
Loco 是一个非常强大的入门框架,通过实施订阅支付,我们成功地为正在打造的潜在 SaaS 产品拼上了最后一块拼图。
想了解更多?
文章来源:https://dev.to/shuttle_dev/a-full-stack-saas-template-with-loco-43ak