如何在 Angular 支付流程中管理 Premium Account?
结论
无需为 premium 用户复制一套项目创建路由,也不应把 Auth.isPremium() 作为免支付的最终判断依据。
建议采用以下设计:
- 所有用户共用项目创建流程和路由。
- 前端统一封装 premium 权益判断,只用于调整页面展示和交互。
- 后端重新校验用户是否拥有有效的 premium 权益,并决定价格是否为
0。 - 只有应付金额大于
0时才进入支付流程。 - 项目创建、价格确认和支付状态均由后端统一控制。
前端权限检查可以改善用户体验,但不能充当安全边界。浏览器中的 JavaScript、路由参数和用户对象都可能被修改。
将角色和付费权益分开
artist 通常是权限角色,表示用户可以执行哪些操作。premium 更接近订阅权益,可能涉及有效期、暂停、退款和套餐等级等状态。项目规模较小时,可以暂时使用角色数组:
user.roles = ['artist', 'premium'];
业务变复杂后,最好将角色和订阅分开存储:
user.roles = ['artist'];
user.subscription = {
plan: 'premium',
status: 'active',
expiresAt: '2026-12-31T23:59:59Z'
};
后端判断 premium 权益时,不能只检查某个字符串是否存在,还要检查订阅状态和有效期:
function hasPremiumAccess(user, now = new Date()) {
const subscription = user.subscription;
return Boolean(
subscription &&
subscription.plan === 'premium' &&
subscription.status === 'active' &&
new Date(subscription.expiresAt) > now
);
}
如果 premium 永久有效,不涉及订阅生命周期,也可以继续使用角色或权限数组。无论采用哪种数据结构,最终判断都必须在服务端完成。
在前端统一判断权益
在 AngularJS 中,可以把权益判断放进认证或权限服务,避免控制器和模板直接检查角色数组。
app.factory('EntitlementService', function (Auth) {
function hasRole(role) {
var user = Auth.getCurrentUser();
return Boolean(
user &&
Array.isArray(user.roles) &&
user.roles.indexOf(role) !== -1
);
}
return {
isPremium: function () {
return hasRole('premium');
}
};
});
控制器根据判断结果调整页面展示:
app.controller('ProjectFormCtrl', function (
$scope,
EntitlementService,
ProjectService
) {
$scope.isPremium = EntitlementService.isPremium();
$scope.submit = function (formData) {
return ProjectService.create(formData);
};
});
模板可以展示预计的处理方式:
<p ng-if="isPremium">Premium 用户创建项目免费</p>
<p ng-if="!isPremium">提交后将根据服务端返回的金额进入支付流程</p>
这里的 isPremium 只用于界面提示。即使它返回 true,前端也不能据此认定订单金额一定是 0。
无需单独建立 premium 路由
如果只有 premium 用户才能进入项目创建页,可以使用下面这样的路由配置:
$routeProvider.when('/project/:type', {
templateUrl: 'app/project/form/form.html',
controller: 'ProjectFormCtrl',
access: {
requiresLogin: true,
requiredPermissions: ['premium']
}
});
当前需求并不是禁止普通用户创建项目,而是让两类用户采用不同的计价规则:
- 普通用户创建项目需要付费。
- premium 用户创建项目免费。
两类用户执行的是同一种业务操作,因此更适合共用一个路由:
$routeProvider.when('/project/:type', {
templateUrl: 'app/project/form/form.html',
controller: 'ProjectFormCtrl',
access: {
requiresLogin: true,
requiredPermissions: ['artist']
}
});
如果所有已登录用户都可以创建项目,则只需保留 requiresLogin:
$routeProvider.when('/project/:type', {
templateUrl: 'app/project/form/form.html',
controller: 'ProjectFormCtrl',
access: {
requiresLogin: true
}
});
单独设置 premium 路由会造成表单、校验和控制器逻辑重复,也不会更安全,因为前端路由守卫可以被绕过。
由后端计算价格并决定是否支付
整个流程可以这样处理:
- 用户提交项目数据。
- 后端通过登录会话或访问令牌识别用户。
- 后端查询用户最新的角色或订阅状态。
- 后端计算项目价格。
- premium 用户的金额为
0,项目可直接创建或激活。 - 普通用户获得待支付订单,随后进入支付页面。
- 支付完成后,后端通过支付平台回调确认订单并激活项目。
示意接口:
POST /api/projects
Authorization: Bearer <token>
Content-Type: application/json
{
"type": "music",
"name": "New Project"
}
后端伪代码:
async function createProject(req, res) {
const user = await userRepository.findById(req.auth.userId);
if (!user) {
return res.status(401).json({
error: 'UNAUTHENTICATED'
});
}
const projectInput = validateProjectInput(req.body);
const premium = hasPremiumAccess(user);
const amount = premium
? 0
: await pricingService.calculateProjectPrice(projectInput);
if (amount === 0) {
const project = await projectService.create({
ownerId: user.id,
input: projectInput,
status: 'active',
pricingReason: 'premium'
});
return res.status(201).json({
project: project,
paymentRequired: false,
amount: 0
});
}
const pendingProject = await projectService.create({
ownerId: user.id,
input: projectInput,
status: 'pending_payment'
});
const order = await orderService.create({
userId: user.id,
projectId: pendingProject.id,
amount: amount,
currency: 'CNY'
});
return res.status(201).json({
project: pendingProject,
paymentRequired: true,
orderId: order.id,
amount: order.amount,
currency: order.currency
});
}
AngularJS 前端只需根据服务端结果选择下一步:
app.factory('ProjectService', function ($http, $location) {
return {
create: function (formData) {
return $http.post('/api/projects', formData)
.then(function (response) {
var result = response.data;
if (result.paymentRequired) {
$location.path('/payment/' + result.orderId);
} else {
$location.path('/project/' + result.project.id);
}
return result;
});
}
};
});
不要让客户端提交以下字段后直接采信:
{
"isPremium": true,
"price": 0
}
攻击者可以自行构造请求。客户端最多提交项目内容、优惠码等业务输入,最终金额必须由后端计算。
路由守卫仍有作用
路由守卫可以阻止未登录用户进入页面,也可以隐藏用户明确无权使用的功能。它负责导航控制和用户体验,不能替代后端授权。
例如,可以统一监听路由切换:
app.run(function ($rootScope, $location, Auth) {
$rootScope.$on('$routeChangeStart', function (event, next) {
var access = next.access || {};
if (access.requiresLogin && !Auth.isAuthenticated()) {
event.preventDefault();
$location.path('/login');
return;
}
if (
access.requiredPermissions &&
!Auth.hasAllPermissions(access.requiredPermissions)
) {
event.preventDefault();
$location.path('/forbidden');
}
});
});
每个对应的后端接口仍然需要执行身份认证和授权检查。
支付流程中的注意事项
不要依赖缓存中的旧用户信息
用户打开页面时可能还是 premium,但在提交项目前订阅已经过期。用户也可能刚完成升级,而前端缓存尚未更新。因此,后端应在创建订单时读取可信的最新状态。
记录计价结果
订单创建后,应保存当时确定的金额、币种和计价原因:
{
"amount": 0,
"currency": "CNY",
"pricingReason": "premium",
"premiumVerifiedAt": "2026-09-01T10:00:00Z"
}
这些记录便于审计,也有助于处理退款和订阅变更等问题。订单创建后是否会因订阅状态变化而重新计价,需要在业务规则中明确规定。
保证接口幂等
用户可能重复点击提交按钮,网络请求也可能自动重试。创建项目或支付订单的接口应支持幂等键,避免生成多个项目或发生重复扣款:
Idempotency-Key: 8fb37c5d-9d88-4d20-a2fb-30ecaec2ac70
不要把前端跳转视为支付成功
支付页面跳回成功地址,并不代表款项已经到账。后端应通过支付服务端回调或主动查询支付平台来确认交易状态,确认成功后再把项目从 pending_payment 更新为 active。
推荐的职责划分
Auth.isPremium() 并非不能使用,但它只应负责前端展示和导航,不能直接决定用户是否可以免单。
各部分职责可以这样划分:
- Angular 权限服务负责页面提示、按钮状态和导航。
- Angular 路由守卫负责阻止用户进入明显无权访问的页面。
- 后端授权服务负责确认用户能否创建项目。
- 后端权益服务负责确认 premium 是否有效。
- 后端计价服务负责计算最终应付金额。
- 支付服务负责创建订单并确认支付结果。
这种设计只需维护一套项目创建页面,同时可以防止用户通过修改前端状态绕过付款。
备注:内容仅供参考。
