带付费内容功能的 Asp.Net 应用示例与服务器选择
结论
可以实现,但不建议寻找一个下载后就能直接用于收费阅读的完整示例。更稳妥的方案是用 ASP.NET Core 搭建会员制或单篇付费系统,再接入成熟的支付服务商。
基本流程如下:
- 用户注册并登录。
- 用户选择内容或订阅方案。
- 服务器通过支付平台创建订单。
- 支付平台异步通知支付结果。
- 应用确认通知真实有效后,为用户写入访问权限。
- 用户访问受限页面时,服务器检查权限,再决定是否返回正文。
普通计算机可以运行 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"
生产环境应使用托管平台的环境变量或密钥管理服务,并分别配置测试密钥和生产密钥。
处理退款和争议
系统不能只有“付款成功”这一种状态,至少应考虑:
PendingPaidFailedRefundedDisputedCanceled
退款后是否立即撤销访问权限属于业务规则,应在购买前写清楚,并处理支付平台发送的相应异步事件。
避免重复收费和重复授权
用户可能双击按钮,网络也可能重试请求。创建订单和处理 Webhook 时都应保证幂等。支付服务支持幂等键时,应使用本地订单 ID 作为幂等依据。
法律和税务要求
收费内容可能涉及服务条款、隐私政策、退款政策、消费者保护、发票、税务和数字内容许可。具体要求取决于经营主体及用户所在地,需要向当地专业人士或支付服务商确认,不能只根据技术实现作出判断。
建议的起步顺序
先完成一个不接收真实付款的原型:
- 建立注册和登录。
- 创建一篇测试内容。
- 手工向测试用户写入
ContentAccess。 - 验证无权限用户看不到正文。
- 接入支付平台的测试环境。
- 实现并验证 Webhook 签名。
- 测试成功、失败、取消、重复通知和退款。
- 部署到带 HTTPS 的测试环境。
- 完成日志、备份和合规检查后,再启用真实付款。
付款按钮并不是最需要关注的部分。系统必须等服务器确认付款后再授予权限,而且付费正文不能提前发送给无权限用户。围绕这两个条件设计,才能建立可靠的付费访问系统。
备注:内容仅供参考。
