PAYATHON 2026

付款成功后验证新用户的最佳方式是什么?

支付阿杰

结论

不要修改或临时破坏用户的密码。身份验证和访问授权应该分开处理:

  • 密码只用于验证用户身份,并始终以安全哈希的形式保存。
  • 账户状态或订阅状态单独用于判断用户能否使用会员功能。
  • 收到并验证支付处理商的 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. 创建待激活账户

用户提交注册表单后:

  1. 验证并规范化输入。
  2. 使用 Argon2id、bcrypt 等适合存储密码的算法生成哈希。
  3. 创建状态为 pending 的用户和订阅记录。
  4. 生成不可猜测的内部关联标识,例如随机 UUID。
  5. 创建支付会话时,将该标识写入支付商支持的 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 表。

备注:内容仅供参考。