PAYATHON 2026

多页面注册系统:如何维护临时状态数据?

支付小周

结论

最稳妥的方案是:把注册过程设计成可过期的“注册草稿”,临时数据持久化到数据库,session 中只保存草稿 ID,不保存整个表单数组。

草稿数据和正式会员数据应分开存放。用户每完成一步就保存一次,支付成功后,再通过数据库事务把草稿转换为正式记录。未完成的草稿设置过期时间,并由定时任务清理。

这样可以避免 session timeout 导致数据丢失,也能防止未完成的注册进入正式业务表。

为什么不应继续依赖 session

session 适合保存少量身份和流程信息,例如:

user_id
registration_draft_id
csrf_token

复杂且需要长期保留的注册数据不适合放在 session 中,原因包括:

  • session 可能因超时、浏览器关闭、部署或服务器故障而失效。
  • 使用多台应用服务器时,还需要处理 session 共享问题。
  • 嵌套数组没有清晰的数据结构,家庭成员发生增删后更难维护。
  • 未完成的注册不便查询、恢复和排查。
  • 用户同时打开多个标签页时,数据可能互相覆盖。

延长 session 生命周期只能减少问题发生的次数,无法从根本上保证数据可靠。

建立独立的注册草稿模型

可以为注册流程创建一组独立的表:

CREATE TABLE registration_drafts (
    id UUID PRIMARY KEY,
    owner_id UUID NULL,
    access_token_hash VARCHAR(255) NULL,
    status VARCHAR(32) NOT NULL,
    current_step INTEGER NOT NULL DEFAULT 1,
    email VARCHAR(255) NULL,
    membership_type VARCHAR(32) NULL,
    payment_provider_id VARCHAR(255) NULL,
    expires_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL
);

CREATE TABLE registration_draft_members (
    id UUID PRIMARY KEY,
    draft_id UUID NOT NULL,
    first_name VARCHAR(100) NULL,
    last_name VARCHAR(100) NULL,
    date_of_birth DATE NULL,
    relationship VARCHAR(32) NULL,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL,
    FOREIGN KEY (draft_id) REFERENCES registration_drafts(id)
        ON DELETE CASCADE
);

registration_drafts 表示一次注册流程,registration_draft_members 用于保存家庭成员。正式的 memberships、members 等业务表只接收已完成注册的数据。

如果表单结构经常变化,也可以把部分不重要且无须查询的字段放在 JSON 列中。不过,家庭成员需要单独验证、增删和转换,通常更适合放在子表里。

草稿状态可以定义为:

draft
payment_pending
paid
completed
expired
cancelled

状态转换必须由服务端控制,不能采用客户端提交的状态值。

推荐的处理流程

1. 创建草稿

用户开始注册时,生成不可预测的 UUID,并创建草稿记录:

$draftId = uuid_create(UUID_TYPE_RANDOM);

$db->insert('registration_drafts', [
    'id' => $draftId,
    'status' => 'draft',
    'current_step' => 1,
    'expires_at' => date('Y-m-d H:i:s', time() + 48 * 3600),
    'created_at' => date('Y-m-d H:i:s'),
    'updated_at' => date('Y-m-d H:i:s'),
]);

$_SESSION['registration_draft_id'] = $draftId;

session 中只保存 registration_draft_id。即使 session 丢失,系统仍可通过登录账户或安全的恢复链接找回草稿。

2. 每一步都验证并保存

每个页面只处理当前步骤的数据。服务端验证字段后,立即把数据写入草稿表:

$input = validateContactStep($_POST);

$db->update(
    'registration_drafts',
    [
        'email' => $input['email'],
        'current_step' => 2,
        'updated_at' => date('Y-m-d H:i:s'),
    ],
    ['id' => $draftId, 'status' => 'draft']
);

家庭成员应通过明确的新增、修改和删除操作来维护,不要反复覆盖一个大型 session 数组:

$member = validateFamilyMember($_POST);

$db->insert('registration_draft_members', [
    'id' => uuid_create(UUID_TYPE_RANDOM),
    'draft_id' => $draftId,
    'first_name' => $member['first_name'],
    'last_name' => $member['last_name'],
    'date_of_birth' => $member['date_of_birth'],
    'relationship' => $member['relationship'],
    'created_at' => date('Y-m-d H:i:s'),
    'updated_at' => date('Y-m-d H:i:s'),
]);

进入下一步前可以验证当前步骤。支付前还需要进行一次完整验证,因为草稿可能已经被多次编辑。

3. 发起支付

用户填写表单或跳转到支付页面时,不要让数据库事务一直保持开启。这类事务可能持续数分钟,容易引发锁竞争并耗尽连接。

发起支付前:

  1. 重新读取草稿并进行完整验证。
  2. 由服务端计算应付金额。
  3. 创建支付平台的 payment intent 或同类对象。
  4. 保存支付平台返回的交易标识。
  5. 把状态更新为 payment_pending。

金额、会员类型和优惠结果都应由服务端计算,不能直接采用浏览器提交的金额。

$draft = loadDraftForUpdate($draftId);
validateCompleteDraft($draft);

$amount = calculateMembershipPrice($draft);

$payment = $paymentGateway->createPayment([
    'amount' => $amount,
    'currency' => 'CNY',
    'idempotency_key' => 'registration:' . $draftId,
    'metadata' => [
        'registration_draft_id' => $draftId,
    ],
]);

savePaymentReference($draftId, $payment->id);

这里的 API 名称仅作示意,实际字段和调用方式取决于使用的支付服务商。

4. 支付确认后一次性转为正式数据

浏览器跳转到“支付成功”页面,不能作为付款成功的依据。系统应通过支付平台的服务端查询,或经过签名验证的 webhook 来确认交易状态。

确认支付成功后,在一个短数据库事务中创建正式数据:

$db->transaction(function () use ($draftId, $paymentEvent) {
    $draft = loadDraftForUpdate($draftId);

    if ($draft['status'] === 'completed') {
        return; // webhook 重试时保持幂等
    }

    verifyPaymentMatchesDraft($paymentEvent, $draft);
    validateCompleteDraft($draft);

    $membershipId = createMembershipFromDraft($draft);

    foreach (loadDraftMembers($draftId) as $member) {
        createMember($membershipId, $member);
    }

    markDraftCompleted($draftId, $paymentEvent['payment_id']);
});

这一步需要满足以下条件:

  • 使用行锁或乐观锁,防止重复处理。
  • webhook 可以安全重试。
  • 同一笔支付只能创建一次正式会员资格。
  • 付款金额、币种和商户订单号必须与草稿一致。
  • 任意一条正式记录创建失败时,整个事务都应回滚。

如果支付已经成功,但本地转换失败,不要删除草稿。系统应记录失败状态,并支持后台重试或人工处理。

未完成记录并不是坏事

数据库中存在未完成的草稿很正常,需要避免的是把草稿和正式会员数据混在一起。

清理任务可以每天执行一次:

DELETE FROM registration_drafts
WHERE status IN ('draft', 'expired', 'cancelled')
  AND expires_at < CURRENT_TIMESTAMP;

子表使用了 ON DELETE CASCADE,因此相关的家庭成员草稿也会一起删除。

清理策略应结合业务需求和隐私要求制定。例如,普通草稿可以保留 48 小时;已发起支付但状态不明的记录则要保留更久,并先与支付平台对账。具体期限没有统一标准,需要根据用户恢复需求、审计要求和适用的数据保护规则确定。

草稿恢复与安全控制

如果允许匿名用户注册,不能只凭可猜测的数字 ID 访问草稿。可以使用高强度随机恢复令牌,并且只在数据库中保存令牌哈希:

https://example.com/register/resume?token=<random-token>

每次读取或修改草稿时,都要检查:

  • 当前用户是否拥有该草稿,或者恢复令牌是否有效。
  • 草稿是否已经过期。
  • 草稿当前的状态是否允许执行该操作。
  • 请求是否通过 CSRF 防护。
  • 客户端提交的家庭成员是否属于该草稿。

恢复链接应使用 HTTPS,也不要把姓名、邮箱等个人信息直接写进 URL。

数据库之外的选择

Redis 等缓存也能保存注册草稿,并通过 TTL 自动过期,但更适合存放允许丢失的数据。如果注册流程持续时间较长,或者需要支持恢复、客服排查和支付对账,就必须确认 Redis 已配置合适的持久化、备份和高可用机制。

对于涉及支付和多个家庭成员的注册流程,关系型数据库通常更简单可靠。数据库适合管理结构化状态、约束和事务。保存并定期清理少量过期草稿的成本,通常远低于维护复杂 session 代码的成本。

最终结构可以概括为:session 保存草稿标识,草稿表保存流程状态,支付平台保存支付状态,正式业务表只保存已完成的注册数据。最后,通过幂等的短事务把这些数据连接起来。

备注:内容仅供参考。