如何在本地银行支付跳转后保留 Angular form 数据
结论
不要指望 Angular 页面在跳转后继续保留内存中的表单状态。用户离开站点前往银行页面时,原页面可能被卸载。支付完成后的 redirect 相当于一次新的页面访问,Angular 组件会重新初始化。
比较稳妥的处理方式是:
- 支付前将表单保存为服务端草稿。
- 为草稿生成不可预测的
draftId,同时创建支付订单。 - 关联
draftId、支付订单号和银行交易号。 - 通过银行的服务端回调或订单查询接口确认支付结果。
- 用户返回 Angular 页面后,根据
draftId从服务端恢复表单。 - 最终提交时再次验证支付状态,并用幂等机制防止重复提交。
sessionStorage 或 localStorage 可以改善恢复体验,但不能作为唯一的数据保存方案。
为什么不能只保存在 Angular 中
Angular 表单的数据通常保存在组件实例或服务的内存中。例如:
this.form = this.fb.group({
name: [''],
email: [''],
amount: [0]
});
跳转到外部银行页面后,浏览器可能销毁当前页面。用户返回时,即使 URL 没有变化,Angular 应用也会重新加载,原组件实例中的数据已经丢失。
支付过程中还可能出现以下情况:
- 用户已经完成支付,但没有返回商户页面。
- 银行回调先到,用户稍后才 redirect。
- 用户刷新返回页面。
- 用户重复点击支付按钮。
- 银行重复发送支付通知。
- redirect 参数被用户修改。
- 浏览器禁用了存储或清除了站点数据。
所以,支付状态和重要的表单数据都必须由服务端保存并校验。
推荐的处理流程
常见流程如下:
Angular 表单
↓ 保存草稿
业务后端生成 draftId
↓ 创建支付订单
银行支付页面
↓
银行服务端通知业务后端
↓
浏览器 redirect 回 Angular
↓ 根据 draftId 恢复草稿
确认支付状态并最终提交
1. 支付前保存表单草稿
不要让 Angular 直接把完整表单附加到银行返回 URL。支付前应先调用自己的后端:
interface DraftResponse {
draftId: string;
paymentUrl: string;
}
startPayment(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.http.post<DraftResponse>('/api/payment/start', {
formData: this.form.getRawValue()
}).subscribe({
next: ({ draftId, paymentUrl }) => {
sessionStorage.setItem('currentDraftId', draftId);
window.location.assign(paymentUrl);
},
error: () => {
// 显示“无法创建支付订单”等错误信息
}
});
}
后端收到请求后,需要:
- 校验表单字段。
- 保存草稿。
- 生成不可预测的
draftId。 - 生成唯一的商户订单号。
- 调用银行 API 创建支付。
- 关联商户订单号、银行交易号和
draftId。 - 将支付 URL 返回给 Angular。
银行 API 密钥、签名私钥和商户密钥不能保存在 Angular 代码中。所有涉及这些凭据的操作都必须由服务端完成。
2. 设置支付返回地址
假设 Angular 的返回路由是:
https://example.com/payment/return?draftId=<opaque-id>
如果银行支持自定义透传字段,可以传入一个随机引用值,如 draftId,也可以单独生成 state。字段名称和签名方式取决于银行接口,应以对应银行的 API 文档为准。
返回 URL 只应包含不透明的引用标识,不要放入:
- 完整表单 JSON。
- 身份证号、银行卡号等敏感信息。
- 作为支付金额可信依据的数据。
- 未加保护的用户信息。
- 服务端密钥或签名材料。
URL 可能被记录在浏览器历史、服务器日志、分析系统和 Referer 中,不能用作安全存储。
3. 通过服务端通知确认支付
如果银行提供 webhook、server callback 或 payment notification,应优先通过它确认支付结果。
下面只是结构示例。签名算法和字段必须按照实际银行文档实现:
app.post('/api/payment/webhook', async (req, res) => {
const notification = req.body;
const valid = verifyBankSignature(notification);
if (!valid) {
return res.status(400).send('invalid signature');
}
const payment = await paymentRepository.findByMerchantOrderId(
notification.merchantOrderId
);
if (!payment) {
return res.status(404).send('order not found');
}
if (
notification.amount !== payment.expectedAmount ||
notification.currency !== payment.currency
) {
return res.status(400).send('payment mismatch');
}
await paymentRepository.markPaidIdempotently({
merchantOrderId: notification.merchantOrderId,
bankTransactionId: notification.transactionId
});
return res.status(200).send('ok');
});
至少要验证:
- 银行签名或消息认证信息。
- 商户订单号。
- 支付金额。
- 币种。
- 商户编号。
- 交易状态。
- 银行交易号是否已经处理。
不能仅凭 redirect URL 中出现 status=success 就认定付款成功。浏览器端参数可以伪造,redirect 只负责页面导航。
如果银行不提供服务端通知,后端应调用银行的订单查询接口核实状态。不要由 Angular 判断订单是否已经支付。
4. 返回后恢复表单
Angular 返回组件可以从 URL 中读取 draftId。如果银行无法原样返回该值,也可以用 sessionStorage 中保存的值作为备用:
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FormBuilder } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
interface DraftStatus {
formData: Record<string, unknown>;
paymentStatus: 'pending' | 'paid' | 'failed' | 'expired';
}
@Component({
selector: 'app-payment-return',
templateUrl: './payment-return.component.html'
})
export class PaymentReturnComponent implements OnInit {
readonly form = this.fb.group({
name: [''],
email: [''],
amount: [0]
});
paymentStatus: DraftStatus['paymentStatus'] = 'pending';
constructor(
private readonly route: ActivatedRoute,
private readonly fb: FormBuilder,
private readonly http: HttpClient
) {}
ngOnInit(): void {
const draftId =
this.route.snapshot.queryParamMap.get('draftId') ??
sessionStorage.getItem('currentDraftId');
if (!draftId) {
// 显示“无法定位原表单”
return;
}
this.loadDraft(draftId);
}
private loadDraft(draftId: string): void {
this.http
.get<DraftStatus>(
`/api/form-drafts/${encodeURIComponent(draftId)}/status`
)
.subscribe({
next: result => {
this.form.patchValue(result.formData);
this.paymentStatus = result.paymentStatus;
if (result.paymentStatus === 'paid') {
sessionStorage.removeItem('currentDraftId');
}
},
error: () => {
// 显示草稿不存在、已过期或无权访问
}
});
}
}
如果用户返回时状态仍是 pending,可能只是 webhook 还没有到达。前端可以在短时间内轮询后端:
this.http.get<DraftStatus>(`/api/form-drafts/${draftId}/status`);
轮询需要设置合理的间隔和最大次数,不能无限发送请求。也可以使用 SSE 或 WebSocket,不过普通的支付返回流程通常不需要这么复杂。
没有登录系统时如何保护草稿
表单可以公开访问,不代表草稿接口也可以毫无保护。否则,只要猜中某个编号,就可能读取其他人的表单。
可以采取以下措施:
- 使用至少 128 位随机熵生成不可预测的访问令牌。
- 将数据库内部 ID 与对外访问令牌分开。
- 为草稿设置较短的有效期。
- 限制访问频率。
- 只返回恢复页面确实需要的字段。
- 加密敏感数据,或者不保存不必要的字段。
- 支付完成或草稿过期后清理数据。
例如,后端可以生成随机令牌:
import crypto from 'node:crypto';
const draftToken = crypto.randomBytes(32).toString('base64url');
令牌也可以保存在带有 HttpOnly 和 Secure 属性的 Cookie 中,但支付涉及跨站跳转,需要考虑 Cookie 的 SameSite 行为。如果银行通过顶层 GET 导航返回,SameSite=Lax 通常更容易兼容。如果银行通过跨站 POST 返回,则要根据浏览器规则重新设计。具体方案应结合银行的实际返回方式和目标浏览器进行测试,不能只看理论配置。
sessionStorage 和 localStorage 应该怎么用
跳转前可以临时保存表单:
sessionStorage.setItem(
'paymentFormBackup',
JSON.stringify(this.form.getRawValue())
);
返回后再恢复:
const backup = sessionStorage.getItem('paymentFormBackup');
if (backup) {
this.form.patchValue(JSON.parse(backup));
}
这种方式适合作为辅助备份,但限制很多:
- 用户更换浏览器或设备后无法恢复。
sessionStorage通常只在当前标签页中有效。- 浏览器可能清理存储。
- 用户可以修改其中的数据。
- XSS 代码可以读取 Web Storage。
- 如果用户支付成功后没有返回,服务端无法继续处理业务。
- 它不能证明支付状态或支付金额。
不要在 Web Storage 中保存支付凭证、密钥或高度敏感的信息。支付金额也必须由服务端根据订单重新计算和验证。
最终提交要具备幂等性
支付完成后,创建最终业务记录的操作可能被触发多次。用户刷新页面、重复点击,或者银行重复发送 webhook,都可能造成重复请求。
后端需要保证同一支付订单最多只能成功生成一条最终记录:
app.post('/api/form-drafts/:draftId/finalize', async (req, res) => {
const draft = await draftRepository.findByToken(req.params.draftId);
if (!draft) {
return res.status(404).json({ message: 'Draft not found' });
}
const payment = await paymentRepository.findByDraftId(draft.id);
if (!payment || payment.status !== 'paid') {
return res.status(409).json({ message: 'Payment is not confirmed' });
}
const result = await finalizeDraftIdempotently({
draftId: draft.id,
paymentId: payment.id
});
return res.json(result);
});
数据库层最好再增加唯一约束,例如:
CREATE UNIQUE INDEX ux_submission_payment_id
ON submissions(payment_id);
这样即使多个并发请求同时到达,也不会因应用层检查存在时间差而生成重复记录。
需要特别注意的事项
不要信任前端金额
前端提交的金额只能视为输入。后端要根据业务规则、商品或表单内容重新计算实际应付金额,然后将该金额发送给银行。
确认支付时,还要对比通知中的金额、币种和原订单数据。
区分 redirect 与 webhook
两者用途不同:
- redirect:将用户带回商户页面。
- webhook:由银行可靠地通知商户支付结果。
用户付款后可能直接关闭银行页面,因此不能完全依赖 redirect 完成后续业务。
保留失败和取消状态
支付并不总会成功。草稿至少可以区分以下状态:
draft → payment_pending → paid → finalized
↘ failed
↘ expired
↘ cancelled
支付失败或取消后,通常应恢复原表单,让用户修改内容或重新发起支付,不必要求用户重新填写。
避免重复支付
再次发起支付前,应先查询已有订单的状态。待支付订单仍然有效时,可以继续使用原订单。订单已经支付时,应直接进入确认流程。只有原订单失败或过期后,才创建新的支付尝试。
实际选型
这类场景通常会用到:
- Angular Reactive Forms:管理页面表单。
- Angular Router:处理支付返回路由。
HttpClient:保存和恢复草稿,查询支付状态。- 服务端 API:创建草稿、发起支付、验证通知并完成最终提交。
- 数据库:保存草稿、支付订单和业务记录。
- 银行 webhook 或订单查询 API:确认真实的支付状态。
sessionStorage:作为可选的本地备用恢复机制。- HTTPS、随机令牌、签名验证和幂等约束:保证流程的安全性和可靠性。
核心原则很明确:表单通过服务端草稿恢复,支付结果由服务端确认,浏览器 redirect 只负责把用户带回正确的页面。
备注:内容仅供参考。
