付款成功后验证新用户的最佳方式是什么?
结论
不要修改或临时破坏用户的密码。身份验证和访问授权应该分开处理:
- 密码只用于验证用户身份,并始终以安全哈希的形式保存。
- 账户状态或订阅状态单独用于判断用户能否使用会员功能。
- 收到并验证支付处理商的 IPN/Webhook 后,才能把订阅更新为
active。 - 登录成功不等于拥有会员权限。每次访问受保护资源时,仍需检查订阅状态。
这样才能妥善处理续费失败、退款、拒付、取消订阅和订阅过期等情况。
推荐的数据结构
可以在用户表中保存账户状态,再用单独的订阅表记录支付状态:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
account_status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE subscriptions (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
provider VARCHAR(50) NOT NULL,
provider_customer_id VARCHAR(255),
provider_subscription_id VARCHAR(255) UNIQUE,
status VARCHAR(30) NOT NULL DEFAULT 'pending',
current_period_end TIMESTAMP NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
常见状态包括:
pending:已经注册,尚未确认付款active:付款已经验证,订阅有效past_due:续费失败,处于催缴或宽限期canceled:订阅已取消expired:订阅已经到期refunded:款项已退款suspended:账户因其他原因被停用
状态的具体名称并不重要。需要明确的是每种状态允许哪些操作,以及状态应当如何转换。
推荐流程
1. 创建待激活账户
用户提交注册表单后:
- 验证并规范化输入。
- 使用
Argon2id、bcrypt等适合存储密码的算法生成哈希。 - 创建状态为
pending的用户和订阅记录。 - 生成不可猜测的内部关联标识,例如随机 UUID。
- 创建支付会话时,将该标识写入支付商支持的
metadata、custom或同类字段。
$passwordHash = password_hash(
$_POST['password'],
PASSWORD_ARGON2ID
);
// 示例逻辑,实际项目应使用参数化 SQL
$userId = createUser([
'username' => $_POST['username'],
'password_hash' => $passwordHash,
'account_status' => 'pending',
]);
$reference = bin2hex(random_bytes(32));
createPendingSubscription([
'user_id' => $userId,
'payment_reference' => $reference,
'status' => 'pending',
]);
不要用明文密码、密码哈希或可预测的用户 ID 关联支付记录。支付系统只需要一个随机标识,用它安全地映射到内部订单或订阅即可。
2. 等待服务端支付通知
浏览器跳转到“付款成功”页面,只能说明用户回到了网站,不能证明款项已经到账。这个请求可能被伪造,用户也可能在支付通知到达前打开该页面。
账户激活应由验证通过的 IPN/Webhook 触发:
用户注册
↓
创建 pending 账户和订阅
↓
用户前往支付页面
↓
支付商向服务器发送 IPN/Webhook
↓
验证通知真实性和业务字段
↓
以事务方式将订阅更新为 active
3. 验证 IPN/Webhook
具体的验证方法取决于支付处理商的协议,通常至少要检查:
- 通知签名或服务端回查结果是否有效
- 事件类型是否代表实际支付成功,而不只是创建了订单
- 商户账户或收款方是否正确
- 金额、币种和订阅方案是否符合预期
- 外部订单号、客户号或订阅号是否对应当前记录
- 事件是否已经处理
- 订阅有效期是否可信,并符合业务规则
伪代码如下:
function handleWebhook(string $rawBody, array $headers): void
{
$event = verifyProviderWebhook($rawBody, $headers);
if ($event === null) {
http_response_code(400);
return;
}
if (eventAlreadyProcessed($event->id)) {
http_response_code(200);
return;
}
if ($event->type === 'subscription.payment_succeeded') {
databaseTransaction(function () use ($event) {
$subscription = findSubscriptionForUpdate(
$event->subscriptionId
);
if ($subscription === null) {
throw new RuntimeException('Unknown subscription');
}
assertPaymentMatchesExpectedValues(
$event,
$subscription
);
markEventProcessed($event->id);
updateSubscription($subscription->id, [
'status' => 'active',
'current_period_end' => $event->periodEnd,
]);
updateUser($subscription->userId, [
'account_status' => 'active',
]);
});
}
http_response_code(200);
}
这里的 verifyProviderWebhook() 和事件名称只是占位示例。实际项目必须按照支付商的官方协议实现,不能直接信任请求中的字段。
登录和权限检查
可以允许 pending 用户登录,但只能查看付款状态、重新付款或修改基础资料。也可以在登录阶段直接拒绝这类账户。无论选择哪种体验,密码校验和订阅授权都要分开处理:
$user = findUserByUsername($username);
if ($user === null ||
!password_verify($password, $user->password_hash)) {
denyLogin('用户名或密码错误');
}
createLoginSession($user->id);
if ($user->account_status !== 'active') {
redirect('/subscription/status');
}
受保护页面还需再次检查权限,不能只在登录时检查一次:
function requireActiveSubscription(int $userId): void
{
$subscription = findCurrentSubscription($userId);
$hasAccess =
$subscription !== null &&
$subscription->status === 'active' &&
(
$subscription->current_period_end === null ||
$subscription->current_period_end > new DateTimeImmutable()
);
if (!$hasAccess) {
http_response_code(403);
exit('Subscription required');
}
}
如果登录会话持续时间较长,订阅可能在用户登录后过期,也可能被退款或撤销。因此,关键请求应查询当前权限,或者采用缓存时间较短且能及时失效的授权机制。
为什么不应修改密码
临时修改密码再恢复,会带来一系列不必要的风险:
- 需要额外保存原密码或原哈希,增加泄漏和意外覆盖的风险。
- 如果用户在等待付款期间重置密码,可能出现竞态条件。
- IPN 重试或乱序到达时,可能覆盖较新的密码。
- 一旦密码恢复失败,已经付款的用户也无法登录。
- 退款、拒付和续费失败仍然需要另一套账户禁用机制。
- 身份验证与订阅授权被耦合后,维护和审计都会更加困难。
尤其不要为了恢复密码而保存明文密码。密码在注册时就应进行不可逆哈希,此后只通过 password_verify() 一类接口验证。
可靠性和安全注意事项
通知必须支持幂等处理
支付商通常会重试通知。同一个事件重复到达时,系统不能反复延长订阅或重复发放权益。可以为支付商的事件 ID 建立唯一约束:
CREATE TABLE payment_events (
provider VARCHAR(50) NOT NULL,
provider_event_id VARCHAR(255) NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (provider, provider_event_id)
);
在同一个数据库事务中记录事件并更新订阅,可以减少重复处理和部分更新的风险。
不要依赖通知顺序
支付成功、退款、取消和续费失败等事件可能延迟或乱序到达。更新状态时,需要结合事件时间、账期和当前状态判断,不能无条件覆盖。
条件允许时,可以定期调用支付商 API 对账,修复网络故障或通知丢失造成的状态差异。
区分取消与立即失效
用户取消自动续费后,通常仍可使用已经支付的剩余账期。此时可以记录:
status = canceled
current_period_end = 已付款账期结束时间
是否继续允许访问,应由产品规则决定,不能只要看到 canceled 就立即禁用。
处理未完成注册
应为长期处于 pending 状态的账户设置清理策略,例如在一定时间后删除未付款记录,或允许用户重新发起付款。具体保留期限需要结合业务、审计和当地隐私要求确定。
简化方案
如果当前系统规模较小,也可以先只在 users 表中增加字段:
ALTER TABLE users
ADD COLUMN account_status VARCHAR(20) NOT NULL DEFAULT 'pending',
ADD COLUMN paid_until TIMESTAMP NULL;
验证付款后再更新:
UPDATE users
SET account_status = 'active',
paid_until = :period_end
WHERE id = :user_id;
即使只做这一步,也比修改密码安全、清晰得多。以后出现退款、多个订阅、账单历史或支付商事件审计等需求时,再拆分出独立的 subscriptions 和 payment_events 表。
备注:内容仅供参考。
