PAYATHON 2026

如何在 ASP.NET Web Forms 中实现订阅制网站

支付小周

结论

ASP.NET Web Forms 没有内置框架可以完整处理订阅计划、套餐升降级、支付和 credits 余额。

更稳妥的方案是:

  • 用户登录继续使用 ASP.NET Membership、ASP.NET Identity 或现有账户系统。
  • 周期订阅交给 Stripe Billing、PayPal Subscriptions、Braintree 等支付平台处理。
  • 本地数据库只保存客户映射、订阅状态、权益和交易记录。
  • credits 使用独立的余额流水模型,不要塞进订阅表。
  • 页面访问权限统一交给本地的权益服务判断,不要让每个 Web Forms 页面直接查询支付平台。

如果想减少开发量,可以选择提供托管结账页和客户门户的支付平台。这些平台通常能处理付款信息更新、续费、账单历史、取消订阅以及部分套餐升降级流程,但业务权限和 credits 仍要由网站自行管理。

推荐架构

系统可以分为四层:

  1. 账户层:负责用户注册、登录和找回密码。
  2. 计费层:负责创建订阅、续费、退款、取消和支付失败处理。
  3. 权益层:判断用户当前可以使用哪些功能。
  4. credits 账本:记录购买、消费、退款和人工调整。

不要直接用 Gold、Premium 之类的字符串控制页面。应该把计划映射成具体权益,例如:

  • CanExport
  • MaxProjects
  • MonthlyCredits
  • PrioritySupport

以后调整套餐内容时,只需修改权益配置,不必逐页改动业务代码。

数据模型

下面是一个简化模型,字段可以按现有项目调整:

CREATE TABLE SubscriptionPlan (
    PlanId INT IDENTITY PRIMARY KEY,
    Code NVARCHAR(50) NOT NULL UNIQUE,
    DisplayName NVARCHAR(100) NOT NULL,
    IsActive BIT NOT NULL
);

CREATE TABLE UserSubscription (
    UserSubscriptionId BIGINT IDENTITY PRIMARY KEY,
    UserId NVARCHAR(128) NOT NULL,
    PlanId INT NOT NULL,
    Provider NVARCHAR(30) NOT NULL,
    ProviderCustomerId NVARCHAR(200) NULL,
    ProviderSubscriptionId NVARCHAR(200) NOT NULL UNIQUE,
    Status NVARCHAR(30) NOT NULL,
    CurrentPeriodEndUtc DATETIME2 NULL,
    CancelAtPeriodEnd BIT NOT NULL DEFAULT 0,
    UpdatedAtUtc DATETIME2 NOT NULL,
    FOREIGN KEY (PlanId) REFERENCES SubscriptionPlan(PlanId)
);

CREATE TABLE CreditLedger (
    CreditLedgerId BIGINT IDENTITY PRIMARY KEY,
    UserId NVARCHAR(128) NOT NULL,
    Amount INT NOT NULL,
    EntryType NVARCHAR(30) NOT NULL,
    ReferenceId NVARCHAR(200) NOT NULL,
    CreatedAtUtc DATETIME2 NOT NULL,
    CONSTRAINT UQ_CreditLedger_Reference UNIQUE (ReferenceId)
);

CreditLedger.Amount 用正数表示增加,用负数表示消费。余额通过汇总流水计算:

SELECT COALESCE(SUM(Amount), 0)
FROM CreditLedger
WHERE UserId = @UserId;

数据量较大时,可以额外维护余额快照,但仍应保留完整流水,以便审计和处理退款。

接入订阅支付

支付平台的 SDK 和 API 会随版本变化。下面用与供应商无关的接口说明 Web Forms 中的代码组织方式。实际接入时,应以所选平台当前的官方文档为准。

public interface IBillingService
{
    string CreateCheckoutUrl(
        string userId,
        string planCode,
        Uri successUrl,
        Uri cancelUrl);

    string CreateCustomerPortalUrl(
        string providerCustomerId,
        Uri returnUrl);

    void HandleWebhook(
        string payload,
        string signatureHeader);
}

用户点击订阅按钮后,由服务器创建结账会话:

protected void SubscribeButton_Click(object sender, EventArgs e)
{
    if (!User.Identity.IsAuthenticated)
    {
        Response.Redirect("~/Account/Login.aspx");
        return;
    }

    string planCode = PlanList.SelectedValue;

    if (!AllowedPlans.Contains(planCode))
    {
        ErrorLabel.Text = "无效的订阅计划。";
        return;
    }

    string checkoutUrl = BillingService.CreateCheckoutUrl(
        GetCurrentUserId(),
        planCode,
        new Uri(Request.Url, "/Billing/Success.aspx"),
        new Uri(Request.Url, "/Billing/Plans.aspx"));

    Response.Redirect(checkoutUrl, false);
    Context.ApplicationInstance.CompleteRequest();
}

服务器必须重新验证 planCode,不能信任浏览器提交的价格、商品 ID 或 credits 数量。实际收费金额应来自服务器保存的计划配置,或者支付平台中预先创建的 Price/Product ID。

升级与降级

套餐升降级通常有两种处理方式:

  • 升级立即生效:支付平台按比例计算补差价,系统收到成功事件后更新权益。
  • 降级下个周期生效:用户在当前周期内继续保留原有权益,到期后再切换套餐。

应先在业务层确定这些规则,再将其映射到支付平台的订阅更新 API。不能只修改本地数据库里的 PlanId,否则支付平台仍会按原计划扣款。

如果支付平台提供 customer portal,最好让用户通过托管门户修改套餐和付款方式。这样能减少银行卡信息处理、安全合规以及前端支付流程的开发工作。

通过 Webhook 同步状态

支付完成后的浏览器跳转只适合显示结果,不能作为开通权益的依据。用户可能直接关闭页面,也可能伪造请求。订阅状态应由通过签名验证的 webhook 同步。

public partial class BillingWebhook : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (!string.Equals(Request.HttpMethod, "POST",
            StringComparison.OrdinalIgnoreCase))
        {
            Response.StatusCode = 405;
            return;
        }

        string payload;

        using (var reader = new StreamReader(Request.InputStream))
        {
            payload = reader.ReadToEnd();
        }

        string signature = Request.Headers["Payment-Signature"];

        try
        {
            BillingService.HandleWebhook(payload, signature);
            Response.StatusCode = 200;
        }
        catch (InvalidWebhookSignatureException)
        {
            Response.StatusCode = 400;
        }
        catch (Exception)
        {
            Response.StatusCode = 500;
        }
    }
}

Payment-Signature 只是一个占位名称。Stripe、PayPal、Braintree 等平台使用的签名头、验证方法和事件结构各不相同,必须按照对应平台的官方流程验证。

至少要处理以下状态变化:

  • 首次付款成功
  • 续费成功
  • 付款失败
  • 订阅计划变更
  • 订阅取消
  • 订阅到期
  • 退款或争议付款

Webhook 可能重复发送,也可能乱序到达,所以处理逻辑必须具备幂等性。可以把支付平台的事件 ID 保存到数据库,并建立唯一索引。

实现 credits 购买

credits 一般通过一次性付款购买,而不是周期订阅。用户付款成功后,由 webhook 向账本写入一条正数流水:

public void GrantPurchasedCredits(
    string userId,
    int credits,
    string paymentId)
{
    if (credits <= 0)
        throw new ArgumentOutOfRangeException(nameof(credits));

    string referenceId = "purchase:" + paymentId;

    using (var connection = OpenConnection())
    using (var transaction = connection.BeginTransaction(
        IsolationLevel.Serializable))
    {
        InsertCreditLedgerEntry(
            connection,
            transaction,
            userId,
            credits,
            "Purchase",
            referenceId);

        transaction.Commit();
    }
}

消费 credits 时,余额检查和扣减必须放在同一个数据库事务里:

public bool TrySpendCredits(
    string userId,
    int credits,
    string operationId)
{
    if (credits <= 0)
        throw new ArgumentOutOfRangeException(nameof(credits));

    using (var connection = OpenConnection())
    using (var transaction = connection.BeginTransaction(
        IsolationLevel.Serializable))
    {
        int balance = GetCreditBalance(
            connection,
            transaction,
            userId);

        if (balance < credits)
        {
            transaction.Rollback();
            return false;
        }

        InsertCreditLedgerEntry(
            connection,
            transaction,
            userId,
            -credits,
            "Usage",
            "usage:" + operationId);

        transaction.Commit();
        return true;
    }
}

operationId 应由业务操作生成并保持唯一,避免用户重复提交请求时被多次扣费。并发量较大时,还可以使用余额表、行锁或乐观并发控制,但不能先查询余额,再通过另一个请求完成扣减。

统一进行权限判断

Web Forms 页面不应各自判断套餐名称。可以建立统一的权益服务:

public interface IEntitlementService
{
    bool HasFeature(string userId, string featureCode);
    int GetCreditBalance(string userId);
}

在页面中调用:

protected void Page_Load(object sender, EventArgs e)
{
    if (!User.Identity.IsAuthenticated)
    {
        Response.Redirect("~/Account/Login.aspx");
        return;
    }

    string userId = GetCurrentUserId();

    if (!EntitlementService.HasFeature(userId, "CanExport"))
    {
        Response.StatusCode = 403;
        ExportPanel.Visible = false;
        UpgradePanel.Visible = true;
        return;
    }

    ExportPanel.Visible = true;
}

敏感操作还需要在实际执行的方法中再次校验权限。仅仅隐藏按钮,无法阻止用户直接构造 HTTP 请求。

现有方案如何选择

选择支付平台时,可以重点比较这些能力:

  • 是否支持目标国家、币种和付款方式
  • 是否同时支持周期订阅和一次性付款
  • 是否支持按比例计费、试用期、优惠券和税务处理
  • 是否提供托管结账页和客户门户
  • Webhook、退款、争议付款和失败重试机制是否完善
  • 是否有 ASP.NET 可用的官方或稳定 SDK
  • 手续费、结算周期和合规要求

如果目标是快速上线,通常没有必要寻找一个包办所有业务的 Web Forms 开源框架。即使存在这样的框架,也很难替代支付平台持续进行的合规维护,其套餐和 credits 模型也未必适合具体业务。

更实用的做法是复用支付平台的 Billing 能力,在现有项目里只实现一层轻量的适配器、权益服务和 credits 账本。

注意事项

  • 不要在服务器、数据库或日志中保存完整银行卡号、CVV 等敏感信息。
  • API secret 和 webhook secret 应保存在受保护的配置中,不能提交到源代码仓库。
  • 所有金额都应使用最小货币单位或 decimal,不要使用 float、double。
  • 不要根据客户端传来的金额创建订单。
  • 本地订阅状态只是支付平台状态的缓存,需要定期对账。
  • 提前确定退款后如何收回 credits,以及 credits 已被消费时如何处理。
  • 明确订阅赠送的 credits 是否可以结转、何时过期,以及用户降级后是否保留。
  • 时间统一以 UTC 保存,显示时再转换成用户所在时区。
  • 上线前使用支付平台的测试环境验证续费失败、重复 webhook、乱序事件、退款和取消等异常流程。

已有的 Web Forms 项目不必为了接入订阅功能迁移整个技术栈。将支付、权益和 credits 分开建模,并通过 webhook 同步计费状态,就能在现有项目中支持订阅制业务。

备注:内容仅供参考。