PAYATHON 2026

带付费内容功能的 Asp.Net 应用示例与服务器选择

支付老李

结论

可以实现,但不建议寻找一个下载后就能直接用于收费阅读的完整示例。更稳妥的方案是用 ASP.NET Core 搭建会员制或单篇付费系统,再接入成熟的支付服务商。

基本流程如下:

  1. 用户注册并登录。
  2. 用户选择内容或订阅方案。
  3. 服务器通过支付平台创建订单。
  4. 支付平台异步通知支付结果。
  5. 应用确认通知真实有效后,为用户写入访问权限。
  6. 用户访问受限页面时,服务器检查权限,再决定是否返回正文。

普通计算机可以运行 ASP.NET 应用,适合本地开发和演示。正式收费网站更适合部署在云服务器、托管平台或容器平台上。生产环境还需要 HTTPS、稳定的公网连接、数据库备份、监控和安全更新,家用电脑通常不适合承担这些工作。

推荐的系统结构

一个最小可用系统通常需要:

  • ASP.NET Core MVC 或 Razor Pages
  • ASP.NET Core Identity,用于注册、登录和用户管理
  • Entity Framework Core,用于保存内容、订单和访问权限
  • SQL Server、PostgreSQL 等关系型数据库
  • Stripe、PayPal 或所在地可用的合规支付服务
  • HTTPS 和密钥管理
  • 邮件服务,用于验证邮箱、重置密码和发送收据

数据关系可以简化为:

  • ContentItem:可销售的内容
  • Order:支付订单
  • ContentAccess:用户已经购买的内容
  • ApplicationUser:登录用户

如果出售的是全站会员,可以用带有到期时间的订阅记录替代 ContentAccess。

第一步:创建项目

以下命令使用当前 ASP.NET Core SDK 中常见的项目模板。实际支持的参数取决于本机安装的 SDK,运行前应通过 dotnet --info 和 dotnet new list 确认。

dotnet new mvc --auth Individual -o PaidContentSite
cd PaidContentSite
dotnet run

--auth Individual 会添加个人账户认证所需的基础结构。项目生成后,还要根据所选数据库配置连接字符串和迁移。

第二步:建立内容与授权模型

下面是简化后的模型。生产项目还应加入货币、订单状态、唯一约束和审计字段。

public sealed class ContentItem
{
    public int Id { get; set; }

    public required string Title { get; set; }

    public required string Body { get; set; }

    // 金额使用最小货币单位,避免浮点数误差。
    // 例如 990 表示 9.90 元,具体币种由 Currency 决定。
    public long PriceMinor { get; set; }

    public required string Currency { get; set; }

    public bool IsPublished { get; set; }
}

public sealed class ContentAccess
{
    public int Id { get; set; }

    public required string UserId { get; set; }

    public int ContentItemId { get; set; }

    public DateTimeOffset GrantedAt { get; set; }

    public required string OrderId { get; set; }
}

订单记录至少应保存以下字段:

public sealed class Order
{
    public string Id { get; set; } = Guid.NewGuid().ToString("N");

    public required string UserId { get; set; }

    public int ContentItemId { get; set; }

    public long AmountMinor { get; set; }

    public required string Currency { get; set; }

    public required string Status { get; set; }

    public string? PaymentProviderReference { get; set; }

    public DateTimeOffset CreatedAt { get; set; }
}

订单金额必须由服务器按照数据库中的商品价格生成,不能采用浏览器提交的金额。否则,访问者可以修改请求,把价格改成任意数值。

第三步:在服务器端保护正文

隐藏按钮、遮盖正文或用 JavaScript 判断用户是否已经购买都不安全,因为完整内容可能已经发送到浏览器。只有服务器确认用户有权访问后,才能返回付费正文。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

[Authorize]
public sealed class ContentController : Controller
{
    private readonly ApplicationDbContext _db;

    public ContentController(ApplicationDbContext db)
    {
        _db = db;
    }

    [HttpGet("/content/{id:int}")]
    public async Task<IActionResult> Details(int id)
    {
        var item = await _db.ContentItems
            .SingleOrDefaultAsync(x => x.Id == id && x.IsPublished);

        if (item is null)
            return NotFound();

        var userId = User.FindFirst(
            System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;

        if (userId is null)
            return Challenge();

        var hasAccess = await _db.ContentAccesses.AnyAsync(x =>
            x.UserId == userId && x.ContentItemId == id);

        if (!hasAccess)
        {
            return View("PurchaseRequired", new PurchaseViewModel
            {
                ContentItemId = item.Id,
                Title = item.Title,
                PriceMinor = item.PriceMinor,
                Currency = item.Currency
            });
        }

        return View(item);
    }
}

还要确保正文不会通过其他接口、搜索索引、RSS、缓存页面或日志泄露。如果使用 CDN 或响应缓存,缓存键必须区分用户权限。更安全的起点是不缓存付费响应。

第四步:创建支付订单

具体 SDK 和 API 会随支付平台及版本变化。下面展示的是与平台无关的结构,不是某家服务商可以直接运行的调用。

[Authorize]
[HttpPost("/checkout/{contentId:int}")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Checkout(int contentId)
{
    var item = await _db.ContentItems
        .SingleOrDefaultAsync(x => x.Id == contentId && x.IsPublished);

    if (item is null)
        return NotFound();

    var userId = User.FindFirst(
        System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;

    if (userId is null)
        return Challenge();

    var order = new Order
    {
        UserId = userId,
        ContentItemId = item.Id,
        AmountMinor = item.PriceMinor,
        Currency = item.Currency,
        Status = "Pending",
        CreatedAt = DateTimeOffset.UtcNow
    };

    _db.Orders.Add(order);
    await _db.SaveChangesAsync();

    var checkout = await _paymentGateway.CreateCheckoutAsync(new()
    {
        LocalOrderId = order.Id,
        AmountMinor = order.AmountMinor,
        Currency = order.Currency,
        Description = item.Title,
        SuccessUrl = Url.Action(
            "PaymentReturn",
            "Payment",
            new { orderId = order.Id },
            Request.Scheme)!,
        CancelUrl = Url.Action(
            "Details",
            "Content",
            new { id = item.Id },
            Request.Scheme)!
    });

    order.PaymentProviderReference = checkout.ProviderReference;
    await _db.SaveChangesAsync();

    return Redirect(checkout.RedirectUrl);
}

_paymentGateway 是项目自行定义的支付抽象,内部调用所选支付平台的官方 SDK。这样不必把支付平台的代码散落在各个控制器中。

第五步:通过 Webhook 发放权限

不能只凭支付成功跳转页发放内容。该地址可能被伪造,用户也可能在付款成功后没有返回网站。应以通过支付服务商签名验证的 Webhook 为依据,并结合服务端查询结果处理。

[AllowAnonymous]
[HttpPost("/payments/webhook")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Webhook()
{
    var notification =
        await _paymentGateway.ReadAndVerifyWebhookAsync(Request);

    if (notification is null)
        return BadRequest();

    if (notification.EventType != "PaymentSucceeded")
        return Ok();

    var order = await _db.Orders
        .SingleOrDefaultAsync(x =>
            x.PaymentProviderReference == notification.ProviderReference);

    if (order is null)
        return NotFound();

    if (order.Status == "Paid")
        return Ok(); // 重复通知时保持幂等

    if (notification.AmountMinor != order.AmountMinor ||
        !string.Equals(
            notification.Currency,
            order.Currency,
            StringComparison.OrdinalIgnoreCase))
    {
        return BadRequest();
    }

    await using var transaction = await _db.Database.BeginTransactionAsync();

    order.Status = "Paid";

    var alreadyGranted = await _db.ContentAccesses.AnyAsync(x =>
        x.UserId == order.UserId &&
        x.ContentItemId == order.ContentItemId);

    if (!alreadyGranted)
    {
        _db.ContentAccesses.Add(new ContentAccess
        {
            UserId = order.UserId,
            ContentItemId = order.ContentItemId,
            OrderId = order.Id,
            GrantedAt = DateTimeOffset.UtcNow
        });
    }

    await _db.SaveChangesAsync();
    await transaction.CommitAsync();

    return Ok();
}

ReadAndVerifyWebhookAsync 必须读取原始请求体,并按照支付平台的规定验证签名、时间戳和事件来源。不能只把收到的 JSON 反序列化,然后直接相信其中的 PaymentSucceeded。

最好在数据库中为“用户 ID + 内容 ID”建立唯一索引,防止支付平台重试通知时重复授权:

protected override void OnModelCreating(ModelBuilder builder)
{
    base.OnModelCreating(builder);

    builder.Entity<ContentAccess>()
        .HasIndex(x => new { x.UserId, x.ContentItemId })
        .IsUnique();

    builder.Entity<Order>()
        .HasIndex(x => x.PaymentProviderReference)
        .IsUnique();
}

服务器如何选择

本地开发

能够安装兼容 .NET SDK 的 Windows、Linux 或 macOS 计算机,通常都可以运行开发环境:

dotnet run

通过浏览器访问 localhost 即可。此时不需要购买服务器,也不应直接接收真实付款。

支付 Webhook 需要一个可以通过公网访问的 HTTPS 地址。开发阶段可以使用支付平台提供的本地转发工具,或可信的临时隧道服务,但必须妥善保护调试端点和密钥。

正式部署

常见的部署方式包括:

  • 支持 ASP.NET Core 的应用托管平台
  • Windows 或 Linux 云服务器
  • 容器托管服务
  • 云厂商提供的 Web App 类托管服务

对于初次部署,托管平台通常比自行维护虚拟机省事,运行时、HTTPS、日志和部署流程也更容易管理。选择平台时应确认:

  • 支持项目使用的 .NET 运行时
  • 可以绑定自有域名并启用 HTTPS
  • 支持环境变量或密钥管理
  • 可以连接托管数据库
  • 可以接收支付平台的 Webhook
  • 提供日志、监控和备份能力
  • 数据中心位置及支付服务符合目标地区要求

家用电脑能否作为正式服务器

技术上可以,但通常不值得。至少需要解决以下问题:

  • 公网 IP、端口转发和运营商限制
  • 路由器与防火墙配置
  • 动态 IP 或域名解析
  • HTTPS 证书
  • 断电、断网和硬件故障
  • 操作系统及依赖的持续安全更新
  • 数据库备份与异地恢复
  • 入侵检测、日志和流量攻击
  • 支付服务商对公开回调地址的要求

家用电脑适合用作开发机,不适合作为面向付费用户的生产服务器。

上线前必须注意的事项

不要保存银行卡信息

银行卡号、验证码等敏感支付信息应由合规支付平台直接收集。优先使用支付平台托管的结账页面或官方安全组件,避免让支付信息经过自己的服务器。具体合规责任取决于地区、业务模式和支付平台的要求。

正确保管密钥

不要把支付密钥和数据库密码写进源码或提交到版本库。开发环境可以使用 .NET Secret Manager:

dotnet user-secrets init
dotnet user-secrets set "Payments:ApiKey" "development-key"

生产环境应使用托管平台的环境变量或密钥管理服务,并分别配置测试密钥和生产密钥。

处理退款和争议

系统不能只有“付款成功”这一种状态,至少应考虑:

  • Pending
  • Paid
  • Failed
  • Refunded
  • Disputed
  • Canceled

退款后是否立即撤销访问权限属于业务规则,应在购买前写清楚,并处理支付平台发送的相应异步事件。

避免重复收费和重复授权

用户可能双击按钮,网络也可能重试请求。创建订单和处理 Webhook 时都应保证幂等。支付服务支持幂等键时,应使用本地订单 ID 作为幂等依据。

法律和税务要求

收费内容可能涉及服务条款、隐私政策、退款政策、消费者保护、发票、税务和数字内容许可。具体要求取决于经营主体及用户所在地,需要向当地专业人士或支付服务商确认,不能只根据技术实现作出判断。

建议的起步顺序

先完成一个不接收真实付款的原型:

  1. 建立注册和登录。
  2. 创建一篇测试内容。
  3. 手工向测试用户写入 ContentAccess。
  4. 验证无权限用户看不到正文。
  5. 接入支付平台的测试环境。
  6. 实现并验证 Webhook 签名。
  7. 测试成功、失败、取消、重复通知和退款。
  8. 部署到带 HTTPS 的测试环境。
  9. 完成日志、备份和合规检查后,再启用真实付款。

付款按钮并不是最需要关注的部分。系统必须等服务器确认付款后再授予权限,而且付费正文不能提前发送给无权限用户。围绕这两个条件设计,才能建立可靠的付费访问系统。

备注:内容仅供参考。