多页面注册系统:如何维护临时状态数据?
结论
最稳妥的方案是:把注册过程设计成可过期的“注册草稿”,临时数据持久化到数据库,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. 发起支付
用户填写表单或跳转到支付页面时,不要让数据库事务一直保持开启。这类事务可能持续数分钟,容易引发锁竞争并耗尽连接。
发起支付前:
- 重新读取草稿并进行完整验证。
- 由服务端计算应付金额。
- 创建支付平台的 payment intent 或同类对象。
- 保存支付平台返回的交易标识。
- 把状态更新为
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 保存草稿标识,草稿表保存流程状态,支付平台保存支付状态,正式业务表只保存已完成的注册数据。最后,通过幂等的短事务把这些数据连接起来。
备注:内容仅供参考。
