支付宝和微信支付是目前国内最为流行的两种移动支付工具,在很多ToC甚至ToD的开发场景下都会接触到支付接入的情景,今天就来研究一下支付接入背后的奥秘。

支付宝

在前端网站中接入支付宝用于支付,这在今天已经是一种很普及并且很常见的方式,我们在浏览器中通过调用支付宝的SDK就可以实现在线支付。

在开发之前建议看一下支付宝开放平台的相关文档,不论是电脑网站还是移动网站,亦或是APP,都有详细的文档,我们今天的内容将基于电脑网站接入为例进行演示,其他方式基本同理。

另外,在支付宝接入应用需要资质审核,个人一般没法进行应用接入测试,好在支付宝提供了一个沙箱环境给开发者进行学习使用,沙箱和线上功能基本一致,如果需要发到线上至需要替换一些密钥之类的配置即可。

接入配置

首先登陆支付宝开发者平台,创建一个沙箱应用,系统会自动分配一个商家账号和一个用户账号,分别用于验证收款和付款场景。

image-20230522215243920

然后我们需要配置开发信息,首先配置一下加签的方式,这里可以一般是直接使用系统默认密钥,如果有特殊需求支付宝也支持自定义密钥,两种方式都有两种模式——公钥和证书。这两种模式对应的SDK使用方式上的区别已经在官方的文档中写清楚了,可以去查不同语言对应的SDK文档(这里以NodeJS为例)。

image-20230522215708566

值得注意的是,沙箱环境调用需要指定网关地址,否则默认为生产环境

image-20230523212501891

这个网关地址可以在沙箱控制台查看

image-20230523212532509

2023-5-24 支付宝沙箱进行升级,新的网关地址调整为https://openapi-sandbox.dl.alipaydev.com/gateway.do

支付宝收银台

这种形式比较原始,创建订单之后跳转至支付宝收银台进行付款,付款成功后根据配置的回调地址进行跳转,现在的淘宝网还是这种形式。

在支付宝开发者文档中对于电脑网站的支付流程有这样一个UML描述

img

注意:商家系统接收到异步通知以后,必须通过验签(验证通知中的 sign 参数)来确保支付通知是由支付宝发送的。具体的验签方式可见文档

我们基于MidwayJS 进行开发,可以编写一个支付宝接入的单例服务

import {
  Autoload,
  Config,
  Init,
  Provide,
  Scope,
  ScopeEnum,
} from '@midwayjs/decorator';
import AlipaySdk from 'alipay-sdk';

@Autoload()
@Provide()
@Scope(ScopeEnum.Singleton)
export class AlipayService {
  alipaySdk: AlipaySdk;

  @Config('alipay')
  alipayConfig;

  @Init()
  async init() {
    this.alipaySdk = new AlipaySdk(this.alipayConfig);
  }

  /**
   * 调用alipay sdk 生成收银台地址
   * @param outTradeNo 订单编号
   * @param productCode 产品编码
   * @param subject 订单标题
   * @param body 订单描述
   * @param totalAmount 总金额
   */
  pageExec(
    outTradeNo: string,
    productCode: string,
    subject: string,
    body: string,
    totalAmount: string
  ) {
    const bizContent = {
      out_trade_no: outTradeNo,
      product_code: productCode,
      subject: subject,
      body: body,
      total_amount: totalAmount,
    };

    return this.alipaySdk.pageExec('alipay.trade.page.pay', {
      method: 'GET',
      notify_url: 'http://ip:8080/pay',
      bizContent,
    });
  }
}

pageExec 方法的method参数可以控制请求结果,如果是POST会返回一段html form代码,通过网页渲染即可重定向到收银台,如果是GET则会返回URL,直接访问即可打开收银台。

注意:电脑网站的product_code只支持FAST_INSTANT_TRADE_PAY,我在这里卡了好久,如果你也碰到了INVALID_PARAMETER错误可以检查一下这个参数。

在业务代码中调用这个服务的方法

@Provide()
export class PayService {
  @InjectEntityModel(OrderModel)
  orderModel: Repository<OrderModel>;

  @Inject()
  alipayService: AlipayService;

  /**
   * 模拟生成订单号
   */
  generateOutTradeNo() {
    return new Date().getTime() + '';
  }

  /**
   * 创建订单(应该根据定价计算)
   */
  async createOrder(amount: number) {
    const order = new OrderModel();
    order.amount = amount.toFixed(2);
    order.realAmount = amount.toFixed(2);
    order.outerTradeNo = this.generateOutTradeNo();
    const user = new UserModel();
    user.userId = '1';
    order.user = user;

    const url = this.alipayService.pageExec(
      order.outerTradeNo,
      ALIPAY_PRODUCT_CODE,
      ALIPAY_SUBJECT,
      ALIPAY_BODY,
      order.amount
    );

    await this.orderModel.save(order);

    return url;
  }
}

这里补充一下订单的实体类,这个只是临时写的,不具太多参考价值

/**
 * 订单状态
 */
export enum OrderStatus {
  PROGRESS, // 进行中
  FINISHED, // 已完成
  CANCELED, // 已取消
  REFUNDED, // 已退款
}

@EntityModel('order')
export class OrderModel {
  @PrimaryGeneratedColumn()
  orderId: number;

  @Column({
    comment: '交易金额',
  })
  amount: string;

  @Column({
    comment: '实际交易金额',
  })
  realAmount: string;

  @Column({
    type: 'enum',
    enum: OrderStatus,
    default: OrderStatus.PROGRESS,
    comment: '0 进行中 1已完成 2已取消 3已退款',
  })
  status: OrderStatus;

  @Column({
    comment: '订单号',
  })
  outerTradeNo: string;

  @CreateDateColumn({
    name: 'create_time',
    type: 'datetime',
    comment: '创建时间',
  })
  createTime: string;

  @UpdateDateColumn({
    name: 'update_time',
    type: 'datetime',
    comment: '编辑时间',
  })
  updateTime: string;

  @ManyToOne(type => UserModel, userModel => userModel.orders)
  user: UserModel;
}

在controller层进行返回,我这里一切从简,直接在借口重定向计算得到的url

@Controller('/pay')
export class PayController {
  @Inject()
  payService: PayService;

  @Inject()
  ctx: Context;

  @Logger()
  logger: ILogger;

  @Get('/test')
  async test() {
    const url = await this.payService.createOrder(100);
    this.ctx.redirect(url);
  }
}

此时在浏览器直接访问API地址即可跳转至支付宝的收银台,可以使用账号密码登陆后支付

image-20230524201345564

也可以使用手机APP(开发者中心提供的沙箱版支付宝APP)进行扫码支付。

c16c30cde872a10f0e675d26659482e9

确认支付之后可以在页面上看到结果,此时也可以查看我们沙箱账号中的余额是否更新。

image-20230524201224888

支付结果查询

现在只是支付成功,但是我们自己系统中的订单状态还没有更新。

再看一下上面的UML

  • 用户确认支付后,支付宝通过 get 请求 returnUrl,返回同步返回参数;
  • 交易成功后,支付宝通过 post 请求 notifyUrl,返回异步通知参数。
  • 由于同步返回的不可靠性,支付结果必须以异步通知或查询接口返回为准,不能依赖同步跳转。还有就是⚠️要验签,这很重要

由于支付宝的通知只能发送到公网,所以我利用云服务器跑了一个小程序来接收通知消息

image-20230524230845028

这个程序会将指定请求的数据写入到文件中方便查看,此时再走一遍之前的支付流程,可以看到下面的文件生成

image-20230524231644407

根据拿到的数据我们就可以开始进行验签了,方法见异步通知验签

如果你使用的框架没有自动格式化body数据,需要手动格式化。判断的方法是复制上面得到的body数据,使用curl命令来进行请求,例如

curl -X POST \
  'https://example.com/api/v1/endpoint' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d '保存下来的请求体'

按照官方文档的意见,我们第一步需要使用sdk进行验签;然后需要二次校验我们的订单数据及商家信息等是否和通知内容一致,有一处不匹配都可以认为数据不合法。

待所有的数据校验完毕之后更新我们自己数据库中的订单状态。

@Controller('/pay')
export class PayController {
  @Inject()
  payService: PayService;

  @Logger()
  logger: ILogger;
  
  // ......

  @Post('/alipaySuccess')
  async success(@Body() params: AlipayNotifyDTO) {
    const res = await this.payService.checkAlipayNotify(params);
    res && this.logger.info('支付成功: ' + params.total_amount);
    await this.payService.over(params.out_trade_no);
    return res;
  }
}

在pay.service中编写相关的方法

@Provide()
export class PayService {
  @InjectEntityModel(OrderModel)
  orderModel: Repository<OrderModel>;

  @Inject()
  alipayService: AlipayService;

  @Logger()
  logger: ILogger;
  
  // ......

  /**
   * 根据订单编号查询订单
   * @param outerTradeNo 订单编号
   */
  async findOrderByOuterTradeNo(outerTradeNo: string) {
    return await this.orderModel.findOne({ where: { outerTradeNo } });
  }

  /**
   * 支付宝订单验签
   * @param notifyObj 通知对象
   */
  async checkAlipayNotify(notifyObj: AlipayNotifyDTO) {
    const signRes = this.alipayService.checkNotifySign(notifyObj);
    if (signRes) {
      const outTradeNo = notifyObj.out_trade_no;
      const order = await this.findOrderByOuterTradeNo(outTradeNo);
      return (
        !!order &&
        order.realAmount === notifyObj.total_amount &&
        this.alipayService.alipayConfig.appId === notifyObj.app_id &&
        notifyObj.seller_id === ALIPAY_SELLER_ID
      );
    } else {
      this.logger.warn('验签失败,sign不合法');
      return false;
    }
  }

  /**
   * 完成订单
   * @param outTradeNo 订单号
   */
  async over(outTradeNo: string) {
    const order = await this.findOrderByOuterTradeNo(outTradeNo);
    order.status = OrderStatus.FINISHED;
    return await this.orderModel.save(order);
  }
}

此时再次使用curl 发送请求来模拟支付宝的通知,可以修改一些数据来检验一下SDK验签是否正常

image-20230525212032398

以支付打样,剩下的关闭订单以及退款可以自行根据业务开发,详细参见文档

网页内嵌扫码支付

有时候觉得跳转支付宝收银台不够优雅,想要直接在页面扫码支付,这种有两个方案可以实现

  1. 自己做一个收银台(小程序,手机H5都可以),手机使用支付宝扫码打开自家的收银台,然后使用手机调用支付宝进行付款(可以参考优酷官网);
  2. 使用iframe内嵌支付宝的二维码,同样也是需要服务端先得到各种参数。
  3. 接入当面付

受条件限制,下面以方案2、3为例进行演示

内嵌二维码

通过 iframe 的形式将支付宝的收款码嵌入到商家的页面中

image-20230525215555398

默认的是跳转模式,这里需要把bizContent的内容添加参数改为前置模式,然后通过iframe的形式进行嵌入。

例如

const bizContent = {
  out_trade_no: outTradeNo,
  product_code: productCode,
  subject: subject,
  body: body,
  total_amount: totalAmount,
  // 和之前生成跳转链接相比,仅仅多了下面两个参数
  qr_pay_mode: '4',
  qrcode_width: 120,
};

然后在页面中添加iframe,饮用这个地址,以vue为例

<iframe
    :src="qrPayUrl" // 从后端请求的 url
    frameborder="no"
    border="0"
    marginwidth="0"
    marginheight="0"
    scrolling="no"
    width="120"
    height="120"
    style="overflow:hidden;">
</iframe>

最终的效果如下,这个二维码会自动刷新.

image-20230525220058502

当面付

当面付有两种场景,商家扫码枪扫付款码和商家设置金额之后生成二维码买家使用扫一扫扫码支付,付款码模式我们不做探究,可以研究一下扫码支付,流程如下。

img

同样是需要服务端生成URL,这里需要把produce_code设置为FACE_TO_FACE_PAYMENT,然后请求方法改为alipay.trade.precreate

@Autoload()
@Provide()
@Scope(ScopeEnum.Singleton)
export class AlipayService {
  alipaySdk: AlipaySdk;

  @Config('alipay')
  alipayConfig;

  // ......

  preCreate(
    outTradeNo: string,
    productCode: string,
    subject: string,
    body: string,
    totalAmount: string
  ) {
    const bizContent = {
      out_trade_no: outTradeNo,
      product_code: productCode, //FACE_TO_FACE_PAYMENT
      subject: subject,
      body: body,
      total_amount: totalAmount,
    };

    return this.alipaySdk.pageExec('alipay.trade.precreate', {
      method: 'GET',
      notify_url: ALIPAY_NOTIFY_URL,
      bizContent,
    });
  }
}

这时候的返回值是仍然是一个URL,我们访问这个地址会拿到一个qr_code

image-20230525223518638

前端使用工具将这个code使用二维码的形式渲染出来,使用支付宝APP扫码即可支付

image-20230525223614951

订单的状态不多介绍,根据文档进行开发即可。

微信支付

说完了支付宝再来看一下微信支付,但是好像微信支付的风评不是很好(对于开发者而言,好像是文档做的不太好,体验了一下确实如此)。

微信没有沙箱环境,卒。

文档去吧。


前端小白