PAYATHON 2026

如何在本地银行支付跳转后保留 Angular form 数据

支付老李

结论

不要指望 Angular 页面在跳转后继续保留内存中的表单状态。用户离开站点前往银行页面时,原页面可能被卸载。支付完成后的 redirect 相当于一次新的页面访问,Angular 组件会重新初始化。

比较稳妥的处理方式是:

  1. 支付前将表单保存为服务端草稿。
  2. 为草稿生成不可预测的 draftId,同时创建支付订单。
  3. 关联 draftId、支付订单号和银行交易号。
  4. 通过银行的服务端回调或订单查询接口确认支付结果。
  5. 用户返回 Angular 页面后,根据 draftId 从服务端恢复表单。
  6. 最终提交时再次验证支付状态,并用幂等机制防止重复提交。

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 只负责把用户带回正确的页面。

备注:内容仅供参考。