PAYATHON 2026

Ionic-Angular PWA 如何按条件关闭外部页面

支付阿杰

结论

同一窗口跳转到外部支付页后,Ionic-Angular 应用中的 JavaScript 就会停止运行,无法继续轮询,也无法主动关闭外部页面。浏览器不允许页面随意关闭当前标签页,window.close() 通常只能关闭由脚本通过 window.open() 创建的窗口。

支付场景更适合采用以下流程:

  1. 用户点击按钮,发起支付。
  2. 服务端创建支付订单并返回支付地址。
  3. 当前窗口跳转到外部支付页。
  4. 支付平台通过 return_url 将用户重定向回 PWA。
  5. 支付平台同时通过 webhook 通知服务端。
  6. PWA 返回后向自己的服务端查询最终支付状态。

如果必须使用新窗口,需要在用户点击事件中同步创建窗口,再异步获取支付地址。否则浏览器会将其视为弹窗并拦截。

为什么当前代码会被拦截

浏览器通常只允许在用户操作的同步调用链中执行 window.open(),例如:

button.addEventListener('click', () => {
  window.open(url);
});

如果先等待 HTTP 请求、Promise、Observable 或定时器,再调用 window.open(),调用就已经脱离了用户点击事件:

this.service.createPayment().subscribe(response => {
  window.open(response.paymentUrl);
});

浏览器此时无法确认弹窗是否由用户主动触发,因此可能直接拦截。移动端浏览器和 PWA 模式的限制往往更严格。

推荐方案:当前窗口跳转并通过回调地址返回

创建支付订单时,将 PWA 中用于接收支付返回结果的页面作为 return_url 提交给支付平台。具体参数名由支付服务商决定,也可能是 redirect_url、success_url 或 callback_url。

startPayment(): void {
  this.paymentService.createPayment({
    returnUrl: `${window.location.origin}/payment/return`
  }).subscribe({
    next: payment => {
      window.location.assign(payment.paymentUrl);
    },
    error: error => {
      console.error('Failed to create payment', error);
    }
  });
}

支付完成或取消后,外部支付平台会将浏览器重定向到类似下面的地址:

https://example.com/payment/return?orderId=12345

Angular 路由可以配置对应的返回页面:

const routes: Routes = [
  {
    path: 'payment/return',
    component: PaymentReturnPage
  }
];

返回页面不能仅凭 URL 中的 status=success 判断支付成功,而应使用订单号向自己的服务端查询:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';

@Component({
  selector: 'app-payment-return',
  template: `
    <p *ngIf="loading">正在确认支付结果……</p>
    <p *ngIf="!loading">{{ message }}</p>
  `
})
export class PaymentReturnPage implements OnInit {
  loading = true;
  message = '';

  constructor(
    private route: ActivatedRoute,
    private router: Router,
    private paymentService: PaymentService
  ) {}

  ngOnInit(): void {
    const orderId = this.route.snapshot.queryParamMap.get('orderId');

    if (!orderId) {
      this.loading = false;
      this.message = '缺少订单信息';
      return;
    }

    this.paymentService.getConfirmation(orderId).subscribe({
      next: result => {
        this.loading = false;

        if (result.status === 'PAID') {
          this.message = '支付成功';
          void this.router.navigateByUrl('/payment/success');
        } else {
          this.message = '支付结果尚未确认';
        }
      },
      error: () => {
        this.loading = false;
        this.message = '暂时无法确认支付结果,请稍后重试';
      }
    });
  }
}

支付结果应以服务端通知为准

标准支付流程通常包含前端跳转和服务端 webhook:

PWA → 自有服务端创建订单 → 外部支付页
                         ↓
PWA 返回页 ← return_url   支付平台
                         ↓
                 webhook → 自有服务端

return_url 的作用主要是把用户带回应用,不能作为可信的支付凭据,原因包括:

  • 用户支付成功后可能直接关闭页面,没有发生重定向。
  • 回调 URL 的查询参数可能被修改。
  • 支付平台的通知与页面跳转之间可能存在时间差。
  • 网络中断可能导致页面显示结果与服务端状态不一致。

服务端收到 webhook 后,应验证支付平台的签名,并以幂等方式更新订单状态。PWA 返回后只查询自有服务端,不直接信任 URL 参数,也不能由浏览器决定订单是否已经支付。

如果用户返回时 webhook 尚未到达,可以进行短时间轮询:

import { interval, of } from 'rxjs';
import {
  catchError,
  exhaustMap,
  startWith,
  takeWhile,
  timeout
} from 'rxjs/operators';

confirmPayment(orderId: string) {
  return interval(3000).pipe(
    startWith(0),
    exhaustMap(() =>
      this.paymentService.getConfirmation(orderId).pipe(
        timeout(10000),
        catchError(() => of({ status: 'PENDING' }))
      )
    ),
    takeWhile(result => result.status === 'PENDING', true)
  );
}

这里使用 exhaustMap,可以避免上一次查询尚未完成时再次发出请求。轮询还需要设置总超时时间或最大次数,并在组件销毁时取消订阅。

必须使用新窗口时的处理方式

如果支付服务要求在新窗口中打开,可以在用户点击时立即创建空白窗口,然后异步获取支付地址:

startPayment(): void {
  const paymentWindow = window.open(
    'about:blank',
    'payment',
    'width=480,height=720'
  );

  if (!paymentWindow) {
    this.showPopupBlockedMessage();
    return;
  }

  paymentWindow.document.title = '正在打开支付页面';

  this.paymentService.createPayment().subscribe({
    next: payment => {
      paymentWindow.location.replace(payment.paymentUrl);
      this.monitorPayment(payment.orderId, paymentWindow);
    },
    error: () => {
      paymentWindow.close();
      this.showPaymentError();
    }
  });
}

因为 window.open() 直接发生在点击处理函数中,被拦截的概率会降低。不过这并非绝对保证。浏览器策略、PWA 显示模式、WebView 配置和用户设置都可能产生影响,因此必须处理返回值为 null 的情况。

轮询到支付状态后,可以尝试关闭该窗口:

import { interval, Subject } from 'rxjs';
import { exhaustMap, startWith, takeUntil } from 'rxjs/operators';

private readonly destroy$ = new Subject<void>();

private monitorPayment(
  orderId: string,
  paymentWindow: Window
): void {
  interval(3000).pipe(
    startWith(0),
    exhaustMap(() => this.paymentService.getConfirmation(orderId)),
    takeUntil(this.destroy$)
  ).subscribe({
    next: result => {
      if (result.status === 'PAID') {
        if (!paymentWindow.closed) {
          paymentWindow.close();
        }

        this.destroy$.next();
        void this.router.navigateByUrl('/payment/success');
      }
    }
  });
}

打开后的支付页通常属于另一个域名。受同源策略限制,应用不能读取其 DOM、URL 或内部状态,但仍可保留窗口引用并尝试调用 close()。只有当这个窗口确实由当前页面的脚本创建时,关闭操作才比较可靠。

如何让外部页面主动通知应用

如果支付平台允许配置返回页面,可以将它重定向到同源页面,再通过 postMessage 通知打开该页面的 PWA:

// payment-callback 页面
if (window.opener) {
  window.opener.postMessage(
    {
      type: 'PAYMENT_RETURNED',
      orderId: '12345'
    },
    window.location.origin
  );

  window.close();
}

主应用收到消息后,再向服务端确认支付状态:

window.addEventListener('message', event => {
  if (event.origin !== window.location.origin) {
    return;
  }

  if (event.data?.type === 'PAYMENT_RETURNED') {
    this.checkPaymentStatus(event.data.orderId);
  }
});

不能把支付平台域名发来的任意消息直接当作支付成功。应用应严格校验 event.origin、消息结构和订单归属,并再次查询服务端。

不建议依赖 history.back()

同一窗口跳转后,外部支付页理论上可以通过以下方式返回:

history.back();

但这种方式不适合用作可靠的支付流程:

  • 外部页面未必允许注入或执行自己的代码。
  • 支付过程中可能经过多个重定向页面。
  • 当前页面的上一条历史记录未必是 PWA。
  • 用户直接访问支付链接时,可能没有可返回的应用页面。

更稳定的做法是明确配置 HTTPS return_url。如果还需要支持原生 Ionic/Capacitor 应用,可以根据支付平台的能力使用 Universal Links、App Links 或自定义 URL Scheme。纯 PWA 应优先使用普通 HTTPS 回调地址。

其他注意事项

  • 不要通过 response.msg.includes('True') 判断状态。服务端最好返回明确的结构化字段,例如 status: 'PAID'。
  • 订单创建、支付状态更新和 webhook 处理都应具备幂等性。
  • 验证订单金额、币种、商户订单号和支付平台交易号,不能只验证一个布尔值。
  • 支付地址和回调地址应使用 HTTPS。
  • 跳转前可以保存必要的订单标识,但不要在 localStorage 或 URL 中保存密钥、签名私钥等敏感信息。
  • 需要处理成功、失败、取消、超时、状态未知和用户关闭窗口等情况。
  • 页面重新打开或 PWA 从后台恢复时,应重新向服务端查询未完成的订单。
  • 轮询只能用于同步状态,不能替代经过签名验证的服务端 webhook。

对于外部支付页,首选方案是”当前窗口跳转 + HTTPS 返回地址 + webhook + 服务端状态确认”。如果支付平台限制必须使用新窗口,可以将新窗口方案作为备选,但不能把关闭外部窗口作为支付流程完成的必要条件。

备注:内容仅供参考。