在本教程中,您将使用 NestJS 微服务构建一个可用于生产环境的 PayPal 支付服务。通过学习本教程,您会了解到如何将支付逻辑独立成专门的微服务、如何利用 gRPC 实现服务之间的通信、如何通过 RabbitMQ 发布支付事件,以及如何使用 Docker 完成整个服务的部署过程。

最终,您将会获得一个可扩展的支付架构,该架构可以在多个业务领域中被重复使用。

目录

引言

并非每个微服务都需要包含支付逻辑。当你在user-serviceorder-servicebilling-service中分散使用PayPal API调用时,就会出现以下问题:

  • 重复的PayPal认证信息及SDK代码

  • 错误处理机制不一致,且缺乏幂等性

  • 支付记录难以被审计

  • 在测试环境与生产环境之间切换非常麻烦

解决方案是创建一个专门的支付微服务,负责处理所有与PayPal相关的交互。其他服务通过gRPC与该微服务进行通信,而支付结果会通过RabbitMQ发送给相关业务服务,以便它们能够更新自己的数据。

本指南将使用实际开发环境中的技术栈来讲解这一架构模式:

层次 技术
支付服务 NestJS
服务间通信 gRPC
事件总线 RabbitMQ
数据库 PostgreSQL
API接口 API网关(HTTP)
容器化技术 Docker Compose
PayPal API Orders v2(创建订单、批准支付、捕获交易信息)

为什么需要专门的支付服务?

专门的支付服务将所有与支付相关的功能集中到一个地方。各微服务无需直接与PayPal进行交互,只需向该支付服务发起支付请求即可。

这个服务负责处理PayPal认证、订单创建、交易信息捕获、钱包管理、账本记录以及Webhook事件的处理。而业务服务则可以专注于处理诸如学生申请或订阅等业务逻辑。

业务服务只需要了解以下信息:

  1. 应收取的费用金额

  2. 付款方是谁

  3. 这笔支付是用于哪项业务活动(通过referenceId识别)

  4. 支付完成后应将用户重定向到哪个页面(returnUrl / cancelUrl

  5. 它们不需要掌握PayPal的认证信息。

    架构概述

    用户在前端发起支付请求,这些请求会通过API网关被转发到学生服务。该服务使用gRPC与支付服务进行通信,而支付服务则负责处理所有与PayPal相关的交互。

    一旦支付完成,支付服务会向RabbitMQ发布事件,这样学生服务就可以异步地更新支付状态。

    ┌────────────────────────────────────────────────────────────┐
    │                     表示层                         │
    ├────────────────────────────────────────────────────────────┤
    │ 前端(React)                                                       │
    └───────────────────────┬────────────────────────────────────┘
                            │ HTTP
                            ▼
    
    ┌────────────────────────────────────────────────────────────┐
    │                       网关层                         │
    ├────────────────────────────────────────────────────────────┤
    │ student-apigw                                                       │
    └───────────────────────┬────────────────────────────────────┘
                            │ gRPC
                            ▼
    
    ┌────────────────────────────────────────────────────────────┐
    │                       业务逻辑层                         │
    ├────────────────────────────────────────────────────────────┤
    │ students-service                                                    │
    └───────────────────────┬────────────────────────────────────┘
                            │ gRPC
                            ▼
    
    ┌────────────────────────────────────────────────────────────┐
    │                      支付层                         │
    ├────────────────────────────────────────────────────────────┤
    │ payment-service                                                    │
    │                                                            │
    │ • 创建支付请求                                                     │
    │ • 捕获交易信息                                                     │
    │ • 管理钱包                                                       │
    │ • 更新账本                                                       │
    │ • 处理Webhook事件                                                 │
    │ • 发布事件通知                                                     │
    └──────────────┬───────────────────────┬─────────────────────┘
                   │                       │
                   │ REST                  │ RabbitMQ
                   ▼                       ▼
    
          ┌───────────────┐      ┌────────────────────┐
          │    PayPal     │      │   payment_events   │
          │   结账流程    │      │       队列        │
          └───────────────┘      └─────────┬──────────┘
                                           │
                                           ▼
    
                               ┌────────────────────┐
                               │ students-service   │
                               │ 事件监听器     │
                               └────────────────────┘
    

    支付状态机

    支付状态机用于描述支付的整个生命周期,跟踪其从创建到完成(或失败)的整个过程。每个状态都能反映支付的当前状况,这样便更便于进行监控、重试操作,以及避免出现无效的操作。

    NOT_STARTED → EXECUTING → SUCCESS
                          └→ FAILED
    • NOTStarted — 订单记录已创建在数据库中

    • EXECUTING — PayPal订单已创建,正在等待用户确认

    • SUCCESS — 资金已成功扣收,账本信息已更新,相关事件也已发布

    • FAILED — 扣款失败或用户取消了操作

    先决条件

    在开始之前,请确保您具备以下条件:

    需要了解的PayPal相关概念

    在集成PayPal之前,了解一些核心概念会很有帮助。PayPal为开发环境和生产环境提供了不同的环境设置,同时也会规定应用程序应遵循的订单处理流程。

    沙盒环境与生产环境

    环境类型 API基础地址 结账页面地址
    沙盒环境(开发用) https://api-m.sandbox.paypal.com https://www.sandbox.paypal.com/checkoutnow?token=...
    生产环境(正式使用) https://api-m.paypal.com https://www.paypal.com/checkoutnow?token=...

    请始终在沙盒环境中进行开发,只有在生产环境中才切换到正式环境。

    订单API流程(我们的使用方式)

    PayPal的Orders v2 API分为三个步骤:

    1. 创建订单:后端会生成包含付款金额及退款链接的订单信息

    2. 用户确认:系统会将用户重定向到PayPal页面,由用户完成支付确认操作

    3. 资金扣收:后端会扣除经用户确认的款项

    这与旧版的 Payments REST API有所不同。对于新的集成项目来说,Orders v2是推荐的使用方式。

    环境变量

    PayPal服务会从环境变量中读取配置信息。这种方式能够将敏感的认证信息隐藏在源代码之外,同时也能方便地在沙盒环境和生产环境之间进行切换。

    PAYPAL_CLIENT_ID=your_client_id
    PAYPAL_CLIENT_SECRET=your_client_secret
    PAYPAL_API_BASE=https://api-m.sandbox.paypal.com   # 或 https://api-m.paypal.com(用于生产环境)
    

    切勿将真实的凭证提交到Git中。应使用`.env`文件以及Docker环境注入机制。

    项目结构

    apps/
    ├── core/
    │   └── payment-service/          # 包含所有与PayPal相关的逻辑
    │       ├── src/
    │       │   ├── app/payment/
    │       │   │   ├── paypal/paypal.service.ts
    │       │   │   ├── payment.service.ts
    │       │   │   ├── paymentgrpc.controller.ts
    │       │   │   ├── payment.http.controller.ts
    │       │   │   └── eventspayment-events.publisher.ts
    │       │   ├── migrations/       # 数据库模式变更相关文件
    │       │   └── routes/health.routes.ts
    │       └── Dockerfile
    ├── services/
    │   └── students-service/         # 示例领域服务
    │       └── src/app/payment/
    │           ├── payment-client.service.ts      # gRPC客户端代码
    │           ├── application-payment.service.ts # 业务逻辑处理代码
    │           └── payment-events.consumer.ts     # RabbitMQ事件监听器代码
    └── gateways/
        └── student-apigw/            # 用于前端的HTTP API服务
    libs/
    └── shared/dto/src/lib/payment/
        └── payment.proto             # 共享的gRPC协议文件

    步骤1 — 创建支付服务

    支付服务在同一进程中运行两台服务器。

    协议类型 端口号 用途
    HTTP 3003 用于健康检查、Webhook触发以及管理员API调用
    gRPC 50061 用于服务之间的内部通信

    在同一NestJS应用中,支付服务同时提供了HTTP服务器和gRPC服务器。HTTP服务器负责处理健康检查、Webhook请求以及外部访问请求,而gRPC服务器则用于接收其他微服务发送的内部请求。

    
    // apps/core/payment-service/src/main.ts
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
    
      // 健康检查路由(位于/api前缀之外)
      app.use('/health', healthRouter);
    
      // gRPC微服务
      app.connectMicroservice({
        transport: Transport.GRPC,
        options: {
          package: 'payment',
          protoPath: join(process.cwd(), 'libs/shared/dto/src/lib/payment/paypal.proto'),
          url: `0.0.0.0:${process.env.GRPC_PORT || '50061'}`,
        },
      });
    
      app.setGlobalPrefix('api');
      await app.startAllMicroservices();
      await app.listen(process.env.PORT || 3003);
    }
    

    在启动过程中,NestJS会同时初始化这两台服务器,从而确保外部客户端与内部服务能够通过相应的协议进行通信。

    关键设计原则:HTTP用于处理外部请求及Webhook触发事件;gRPC则用于服务之间的高效、类型化的内部通信。

    步骤2 — 定义gRPC协议文件

    接下来,你需要创建一个共享的`.proto`文件,以确保所有服务都能使用相同的语言进行交互。

    GRPC合约定义了微服务之间共用的API。使用.proto文件可以确保所有服务在与支付服务进行通信时,无论使用何种编程语言,都会采用相同的请求与响应结构。

    // libs/shared/dto/src/lib/payment/paypal.proto
    
    syntax = "proto3";
    package payment;
    
    service PaymentService {
      rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse) {}
      rpc CapturePayment(CapturePaymentRequest) returns (CapturePaymentResponse) {}
      rpc GetPaymentStatus(GetPaymentStatusRequest) returns (GetPaymentStatusResponse) {}
      rpc ListPayments(List PaymentsRequest) returns (ListPaymentsResponse) {}
    }
    
    message CreatePaymentRequest {
      string checkout_id = 1;
      string payment_order_id = 2;
      string domain = 3;           // 例如 "application"、"subscription"
      string reference_id = 4;     // 商业实体ID
      string payer_id = 5;
      string amount = 6;
      string currency = 7;
      string buyer_email = 8;
      string seller_account = 9;
      string payment_category = 10;
      string return_url = 11;      // 成功支付后PayPal的跳转链接
      string cancel_url = 12;      // 取消支付时PayPal的跳转链接
      string idempotency_key = 13;
      string metadata = 14;
      string description = 15;
    }
    
    message CreatePaymentResponse {
      int32 status = 1;
      string message = 2;
      string payment_order_id = 3;
      string paypal_order_id = 4;
      string approve_url = 5;      // 将用户重定向到此链接
      string payment_order_status = 6;
    }
    

    domainreference_id的组合使得某个支付服务能够处理应用程序费用、订阅费用、大学学费等各种类型的付款请求,而无需与任何特定的商业模式发生关联。

    步骤3 — 实现PayPal服务

    现在,您将创建一个专门的PayPalService类,用于封装PayPal的REST API接口。

    我们不会在整个应用程序中直接调用PayPal API,而是将所有与PayPal相关的通信操作都放在这个专门的服务中。这样,认证、订单创建以及支付捕获等逻辑就被集中起来,从而更便于维护。

    // apps/core/payment-service/src/app/paypal/paypal.service.ts
    
    @Injectable()
    export class PayPalService {
      private accessToken: string | null = null;
      private tokenExpiresAt = 0;
    
      private get apiBase(): string {
        return this.configService.get('PAYPAL_API_BASE')
          || 'https://api-m.sandbox.paypal.com';
      }
    
      // 步骤1:获取OAuth访问令牌(缓存直至失效)
      private async getAccessToken(): Promise {
        const now = Date.now();
        if (this.accessToken && now < this.tokenExpiresAt) {
          return this.accessToken;
        }
    
        const response = await axios.post(
          `${this.apiBase}/v1/oauth2/token`,
          'grant_type=client_credentials',
          {
            auth: {
              username: this.configService.get('PAYPAL_CLIENT_ID'),
              password: this.configService.get('PAYPAL_CLIENT_SECRET'),
            },
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
          }
        );
    
        this.accessToken = response.data.access_token;
        this.tokenExpiresAt = now + (response.dataexpires_in - 60) * 1000;
        return this.accessToken;
      }
    
      // 步骤2:创建PayPal结算订单
      async createOrder(input: PayPalCreateOrderInput) {
        const token = await this.getAccessToken();
    
        const response = await axios.post(
          `${this.apiBase}/v2/checkout/orders`,
          {
            intent: 'CAPTURE',
            purchase_units: [
              {
                custom_id: input.paymentOrderId,
                description: input.description,
                amount: {
                  currency_code: input(currency,
                  value: input.amount,
                },
              },
            ],
            application_context: {
              return_url: input.returnUrl,
              cancel_url: input.cancelUrl,
              brand_name: 'YourApp',
              user_action: 'PAY_NOW',
            },
          },
          {
            headers: {
              Authorization: `Bearer ${token}`,
              'PayPal-Request-Id': input.idempotencyKey,
            },
          }
        );
    
        const paypalOrderId = response.data.id;
        const approveUrl = response.datalinks
          ?.find((l) => l.rel === 'approve')?.href;
    
        return { paypalOrderId, approveUrl };
      }
    
      // 步骤3:捕获已批准的订单
      async captureOrder(paypalOrderId: string) {
        const token = await this.getAccessToken();
    
        const response = await axios.post(
          `${this.apiBase}/v2/checkout/orders/${paypalOrderId}/capture`,
          {},
          { headers: { Authorization: `Bearer ${token}` } }
        );
    
        const capture = response.data.purchase_units?.[0]?.payments?.captures?.[0];
        return { status: response.data.status, captureId: capture?.id || '' };
      }
    }
    

    在应用程序启动时,应配置日志记录选项(其中敏感信息会被隐藏),这样你就能一目了然地区分测试环境与生产环境中的数据:

    PayPal配置检查:
      PAYPAL_API_BASE: https://api-m.paypal.com
      PAYPAL_CLIENT_ID: AQb2...aq1M (80个字符)
      credentialsPresent: true
      environment: live
    

    需要注意的是,访问令牌会被缓存,直到其过期为止。这样一来,在每次进行支付操作时就不需要重新请求OAuth令牌了,这既能提升性能,也能减少不必要的API调用。

    步骤4 — 构建支付流程(创建、审批、收款)

    创建支付订单

    PaymentService.createPayment()会执行以下操作:

    1. 检查幂等性键,如果已经存在相应的订单,则直接返回该订单。

    2. 创建一个payment_events结算记录。

    3. 创建一条状态为NOT_STARTEDpayment_orders记录。

    4. 调用PayPalService.createOrder()来创建支付订单。

    5. 将订单状态更新为EXECUTING

    6. approveUrl返回给调用者。

    async createPayment(input: CreatePaymentPayload) {
      // 幂等性检查:防止重复收费
      const existing = await this.paymentOrderModel.findOne({
        where: { idempotencyKey: input.idempotencyKey },
      });
      if (existing) return this.buildCreateResponse(existing);
    
      const order = await this.paymentOrderModel.create({
        paymentOrderId: input.paymentOrderId,
        amount: input.amount,
        currency: input(currency,
        paymentOrderStatus: PaymentOrderStatus NOT_STARTED,
        domain: input.domain,
        referenceId: input/referenceId,
        // ...
      });
    
      const paypalOrder = await this.paypalService.createOrder({
        paymentOrderId: order.paymentOrderId,
        amount: input.amount,
        currency: input.currency,
        returnUrl: input.returnUrl,
        cancelUrl: input.cancelUrl,
        idempotencyKey: input.idempotencyKey,
      });
    
      await order.update({
        paymentOrderStatus: PaymentOrderStatus.EXECUTING,
        paypalOrderId: paypalOrder.paypalOrderId,
      });
    
      return {
        approveUrl: paypalOrder.approveUrl,
        paypalOrderId: paypalOrder/paypalOrderId,
        paymentOrderStatus: PaymentOrderStatus.EXECUTING,
      };
    }
    

    用户在PayPal上进行审批

    前端会将用户重定向到approveUrl。PayPal会处理身份验证和审批流程,之后再把用户重定向回你的returnUrl

    收款

    在获得批准后,可以调用capturePayment()方法,传入paymentOrderIdpaypalOrderId即可:

    async capturePayment(paymentOrderId?: string, paypalOrderId?: string) {
      const order = await this.findOrder(paymentOrderId, paypalOrderId);
    
      if (order.paymentOrderStatus === PaymentOrderStatus SUCCESS) {
        return this.buildCaptureResponse(order); // 收款操作已经完成
      }
    
      const capture = await this.paypalService.captureOrder(order/paypalOrderId);
    
      if (capture.status !== 'COMPLETED') {
        throw new Error(`PayPal收款状态:${capture.status}`);
      }
    
      await this.finalizeSuccessfulPayment(order, capturecaptureId);
      return this.buildCaptureResponse(order);
    }
    

    finalizeSuccessfulPayment()是在数据库事务中执行的:

    1. 将订单状态更新为SUCCESS

    2. 更新卖家的钱包余额

    3. 创建账本记录(审计追踪)

    4. 标记结账操作已完成

    5. 向RabbitMQ发布payment.{domain}.completed事件

    步骤5 — 通过gRPC连接领域服务

    领域服务(如students-service)从不直接与PayPal进行交互。它们会使用gRPC客户端:

    Students Service是通过gRPC客户端与Payment Service进行通信的。它并不会直接调用PayPal的API,而是通过支付服务提供的强类型远程过程来完成相关操作。

    // apps/services/students-service/src/app/payment/payment.module.ts
    
    ClientsModule.registerAsync([
      name: 'PAYMENT_SERVICE',
      useFactory: () => ({
        transport: Transport.GRPC,
        options: {
          package: 'payment',
          protoPath: 'libs/shared/dto/src/lib/payment/payment.proto',
          url: process.env.PAYMENT_SERVICE_URL || 'payment-service:50061',
        },
      }),
    }])
    
    // payment-client.service.ts
    
    @Injectable()
    export class PaymentClientService implements OnModuleInit {
      private paymentService: PaymentGrpcService;
    
      constructor(@Inject('PAYMENT_SERVICE') private client: ClientGrpc) {}
    
      onModuleInit() {
        this.paymentService = this.client.getService('PaymentService');
      }
    
      async createPayment(data: CreatePaymentRequest) {
        return firstValueFrom(this.paymentService.CreatePayment(data));
      }
    
      async capturePayment(data: { payment_order_id?: string; paypal_order_id?: string }) {
        return firstValueFrom(this.paymentService.CapturePayment(data));
      }
    }
    

    领域服务的业务逻辑示例:

    这个示例展示了领域服务如何在将支付处理任务委托给Payment Service之前,先准备特定的业务数据。

    // application-payment.service.ts
    
    async initiateTuitionPayment/applicationId: number, options: { domain: string }) {
      const application = await this.applicationModel.findByPk(applicationId);
    
      // 根据前端域名生成PayPal的返回URL
      const frontendBase = options.domain; // 例如:https://crm.yourapp.com
      const returnUrl = `${frontendBase}/payment/successful?applicationId=${application/applicationId}`;
      const cancelUrl = `${frontendBase}/payment/failure?applicationId=${application.applicationId}`;
    
      const result = await this.paymentClient.createPayment({
        checkout_id: `checkout-app-${application.id}`,
        payment_order_id: uuidv4(),
        domain: 'application',
        reference_id: String(application.id),
        payer_id: application.studentId,
        amount: finalAmount.toFixed(2),
        currency: 'USD',
        buyer_email: buyerEmail,
        seller_account: `university-${application.universityId}`,
        payment_category: 'tuition_deposit',
        return_url: returnUrl,
        cancel_url: cancelUrl,
        idempotency_key: `app-${application.id}-tuition-${uuidv4()`,
      });
    
      return {
        approveUrl: result.approve_url,
        paypalOrderId: result.paypal_order_id,
        payment orderId: result.payment_order_id,
      };
    }
    

    域服务仍然负责处理业务规则,而支付服务则负责处理具体的支付流程。

    重要提示:前端必须将自己所在的域名作为domain参数进行发送,这样返回的URL才能指向正确的环境(开发环境中为localhost,生产环境中为对应的production URL)。

    步骤6 — 添加API网关层

    API网关会向前端暴露HTTP接口,并将请求转发给相应的域服务:

    POST /applications/:id/pay/applicationfee
    Body: { "domain": "https://crm.yourapp.com", "couponCode": "SAVE10" }
    
     student-apigw → students-service (gRPC) → payment-service (gRPC) → PayPal

    API网关的功能包括:

    • 身份验证(使用JWT机制)

    • 请求校验

    • 确保不使用PayPal的认证信息

    在完成PayPal支付流程后,需要发送一个额外的请求来捕获相关数据:

    POST /applications/:id/pay/applicationfee/capture
    Body: { "paypalOrderId": "PAYPAL_ORDER_ID_FROM_URL" }
    

    步骤7 — 使用RabbitMQ发布支付事件

    RabbitMQ能够实现服务之间的异步通信。在支付成功后,支付服务只需发布一个事件,然后由其他相关服务自行处理这个事件即可。

    当支付数据被成功捕获后,支付服务会发布一个事件:

     payment-events.publisher.ts
    
    async publishCompleted(event: PaymentCompletedEvent) {
      const pattern = `payment.${event.domain}.completed`; // 例如:payment.application_completed
      this.eventsClient.emit(pattern, { ...event, eventId: uuidv4() });
    }
    

    每个域服务都会订阅与其业务领域相关的支付事件。例如,学生服务会监听payment/application Completed事件,从而将学生的申请状态标记为“已支付”。

     payment-events.consumer.ts (students-service)
    
    @EventPattern('payment.application_completed')
    async handlePaymentCompleted(@Payload() data: PaymentCompletedPayload) {
      await thisapplicationPaymentService.handlePaymentCompletedEvent(data);
      // 将申请状态标记为“已支付”,并记录支付历史
    }
    

    这种设计使得支付完成的处理过程与域服务的更新操作相互独立。即使students-service暂时无法正常运行,也可以通过RabbitMQ队列来重新处理这些事件。

    标记订单为已支付的两种方式

    方式 适用场景
    同步捕获 前端在完成PayPal支付流程后,立即调用捕获接口
    异步事件处理 RabbitMQ消费者在收到支付服务发布的事件后,再更新域服务的状态

    同时使用这两种方式(并且确保操作具有幂等性),就能保证系统的可靠性:同步处理路径能够立即提供用户反馈,而异步处理路径则起到了安全保障的作用。

    步骤 8 — 数据库架构与迁移

    支付服务拥有自己独立的数据库架构。每个表都有特定的功能,这使得支付记录、金融交易数据以及Webhook处理过程能够与其他业务系统相互隔离。

    表名 用途
    payment_events 结账会话信息(买家/卖家相关信息)
    payment_orders 使用PayPal ID进行的各项支付尝试记录
    ledger_entries 财务审计轨迹记录
    wallets 卖家余额查询功能
    processed_webhooks 用于去除重复的Webhook请求
    coupons / coupon_redemptions 折扣码信息(可选)
    sequelize_meta 用于追踪迁移过程的信息

    生产环境中的迁移注意事项

    在正式的生产环境中使用的Docker镜像中,迁移相关的.ts文件是不可直接使用的,除非你将这些文件编译成JavaScript代码并复制到镜像中:

    # Dockerfile — 为生产环境编译迁移脚本
    RUN pnpm exec tsc --project apps/core/payment-service/tsconfig.migrations.json
    COPY --from=builder /app/dist/apps/core/payment-service/migrations ./migrations
    

    如果不执行上述操作,日志中会显示“Executed 0 migrations”,并且不会创建任何数据库表

    在首次进行部署之前,请先创建相应的数据库用户:

    CREATE USER payment_user WITH PASSWORD 'payment_pass';
    CREATE DATABASE payment_db;
    GRANT ALL PRIVILEGES ON DATABASE payment_db TO payment_user;
    

    步骤 9 — 本地开发环境配置(使用Docker)

    环境变量设置(.env文件)

    PAYPAL_CLIENT_ID=your_sandbox_client_id
    PAYPAL_CLIENT_SECRET=your_sandbox_client_secret
    PAYPAL_API_BASE=https://api-m.sandbox.paypal.com
    

    在本节中,我们将使用Docker Compose来配置支付服务的本地开发环境。这样的设置能够为测试支付功能提供完整的环境,而无需将系统部署到生产环境中。

    Docker Compose(本地环境配置)

    以下配置文件可以同时启动支付服务及其所需的依赖项,包括PostgreSQL和RabbitMQ:

    payment-service:
      build:
        dockerfile: apps/core/payment-service/Dockerfile.dev
      ports:
        - '3003:3003'    # HTTP接口
        - '50061:50061'  # gRPC接口
      environment:
        - PAYPAL_API_BASE=https://api-m.sandbox.paypal.com
        - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}
        - PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET}
        - DB_HOST=postgres
        - DB_NAME=payment_db
        - DB_USER=payment_user
        - DB_PASSWORD=payment_pass
        - RABBITMQ_URL=amqp://rabbitmq:5672
    
    students-service:
      environment:
        - PAYMENT_SERVICE_URL=payment-service:50061
      depends_on:
        payment-service:
          condition: service_healthy
    

    启动服务

    配置完成后,先启动各个容器,确认所有服务都能正常运行,然后再测试支付流程。

    docker compose up -d payment-service students-service student-apigw

    检查服务状态

    curl http://localhost:3003/health
    # 返回结果示例:{"status":"healthy","service":"payment-service",...}
    

    测试支付流程

    1. 发送请求 POST /applications/:id/pay/applicationfee,其中参数 { "domain": "http://localhost:3000" }

    2. 在浏览器中打开返回的 approveUrl

    3. 使用 PayPal沙箱测试账户 登录

    4. 审批通过后,发送请求 POST /applications/:id/pay/applicationfee/capture,并提供 paypalOrderId

    5. 确认申请状态是否为 PAID

    步骤10——生产环境部署

    在本地完成所有测试后,下一步就是将支付服务部署到生产环境中。主要区别在于使用PayPal Live的认证信息、生产环境的配置变量以及专为生产环境准备的Docker镜像。

    PayPal Live认证信息

    1. 访问 PayPal开发者控制台 → 实时应用

    2. 创建一个新的实时REST API应用

    3. 复制客户端ID和密钥

    生产环境配置文件 `.env`(仅保存在服务器上,永不提交更改)

    PAYPAL_CLIENT_ID=your_live_client_id
    PAYPAL_CLIENT_SECRET=your_live_secret
    PAYPAL_API_BASE=https://api-m.paypal.com
    

    Docker Compose配置(生产环境版)

    payment-service:
      build:
        dockerfile: apps/core/payment-service/Dockerfile
      environment:
        - NODE_ENV=production
        - PAYPAL_API_BASE=${PAYPAL_API_BASE:-https://api-m.paypal.com}
        - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}
        - PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET}
        - DB_HOST=${DB_HOST}
        - DB_NAME=payment_db
        - DB_USER=payment_user
        - DB_PASSWORD=payment_pass
        - RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASS}@rabbitmq:5672
      labels:
        - 'traefik.http.routers.payment.rule=Host(`payment-service.yourapp.com`)'
    
    students-service:
      environment:
        - PAYMENT_SERVICE_URL=payment-service:50061
      depends_on:
        payment-service:
          condition: service_healthy
    

    部署命令

    docker compose -f docker-compose.prod.yml build --no-cache payment-service
    docker compose -f docker-compose.prod.yml up -d payment-service students-service
    

    验证生产环境配置

    curl https://payment-service.yourapp.com/health
    
    docker logs -f apply-goal-payment-service
    # 查找以下内容:
    #   environment: live
    #   找到8个待执行的迁移任务
    #   已执行了8个迁移操作
    

    生产环境中的前端域名配置

    在发起支付请求时,前端必须发送生产环境的CRM地址:

    不能使用localhost。这个设置决定了PayPal在完成支付后会将用户重定向到哪个地址。

    步骤11——健康检查与监控

    健康检查功能允许Docker、Traefik等编排工具验证支付服务是否正常运行。对这些接口进行监控有助于及时发现故障,从而提高应用程序的可靠性。

     // GET /health
    { "status": "healthy", "service": "payment-service", "timestamp": "...", "version": "1.0.0" }
    

    以下工具会使用这个接口:

    • Docker的HEALTHCHECK命令

    • Traefik负载均衡器

    • 系统运行状态监控工具

    PayPal的凭证验证会在程序启动时通过PayPalService.logConfiguration()自动执行。

    完整的请求流程示例

    场景:学生为大学申请支付学费。

    1. 前端
       POST /applications/42/pay/applicationfee
       Body: { "domain": "https://crm.yourapp.com" }
            │
            ▼
    2. student-apigw(HTTP → gRPC)
       InitiateApplicationTuitionPayment(applicationId: 42)
            │
            ▼
    3. students-service
       - 验证该申请是否已经支付过
       - 计算应支付的学费金额
       - 可选地通过gRPC接口验证优惠券的有效性
       - 根据提供的域名生成返回地址(returnUrl)和取消链接cancelUrl
       - 调用payment-service的CreatePayment接口(gRPC)
            │
            ▼
    4. payment-service
       - 创建支付订单记录(执行中)
       - 向PayPal发送POST请求,路径为/v2/checkout/orders
       - 返回批准链接approveUrl
            │
            ▼
    5. 前端将用户重定向到approveUrl页面(进行PayPal结算)
            │
            ▼
    6. 用户完成支付确认 → PayPal将用户重定向到returnUrl页面
            │
            ▼
    7. 前端
       POST /applications/42/pay/applicationfee/capture
       Body: { "paypalOrderId": "PAYPAL_ORDER_ID" }
            │
            ▼
    8. payment-service
       - 向/v2/checkout/orders/{id}/capture发送POST请求
       - 更新订单状态 → 操作成功
       - 更新用户的钱包信息及账本记录
       - 将支付完成的状态信息发布到RabbitMQ队列中
            │
            ▼
    9. students-service(事件监听器)
       - 将申请的状态设置为PAID
       - 将支付信息记录到application_payments表中

    优惠券支持(可选功能)

    在创建PayPal订单之前,需要通过gRPC接口验证优惠券的有效性:

    const validation = await this.paymentClient.validateCoupon({
      code: 'SAVE20',
      universityId: application.universityId,
      originalAmount: 500,
      paymentType: 'application_fee',
    });
    
    const finalAmount = validation.data.finalAmount;
    
    // 如果优惠券可以覆盖100%的费用,那么就完全跳过PayPal这个支付环节
    if (finalAmount <= 0) {
      await this.markApplicationPaid(applicationId, { amount: 0, source: 'coupon' });
      return { paymentOrderStatus: 'COMPLETED' };
    }
    

    优惠券相关的逻辑都存储在payment-service模块中,因此折扣规则是集中管理的。

    你可以在PayPal的控制面板中注册一个Webhook地址:

    https://payment-service.yourapp.com/api/v1/payments/webhooks/paypal

    支付服务会处理以下事件:

    事件类型 对应操作
    CHECKOUT_ORDER.APPROVED 自动捕获订单信息
    PAYMENT.CAPTURE.COMPLETED 如果尚未完成支付,则最终确认付款

    通过processed_webhooks表来消除重复处理的现象,从而避免同一事件被多次处理。

    测试检查清单

    • [ ] 执行GET /health请求后应返回200状态码

    • [ ] PayPal的日志中应显示credentialsPresent: true

    • [ ] 启动应用程序后,数据库表(如payment_orderspayment_events等)必须存在

    • [ ] 创建的支付返回信息中必须包含有效的approveUrl

    • [ ] 沙箱环境中的买家能够顺利完成结算流程

    • [ ] 收到退货信息时,payment_order_status字段应显示“SUCCESS”

    • [ ] 在域服务中,应用程序的状态应被标记为“PAID”

    • [ ] RabbitMQ会正确处理类型为payment.application_completed的事件

    • [ ] 对于重复发生的支付请求,系统能够正确进行处理(保证操作的幂等性)

    • [ ] 当使用100%折扣优惠券时,系统应跳过PayPal的支付流程

    • [ > 生产环境应使用https://api-m.paypal.com地址

    总结

    在微服务架构中集成PayPal,主要遵循以下原则:

    1. 所有与PayPal相关的API调用都由同一个支付服务模块负责处理

    2. gRPC用于将域服务内部连接到支付服务模块

    3. RabbitMQ用于传递支付结果,从而保证各域服务之间的解耦

    4. 通过使用幂等性机制,可以避免重复收费的情况发生

    5. 环境变量可用于区分沙箱环境和生产环境,无需修改代码

    6. 迁移脚本必须针对生产环境的Docker镜像进行编译

    7. 前端在发送请求时需要指定domain参数,这样返回的URL才能在所有环境中正常使用

    这种架构具有很好的扩展性:只需为新的支付类型(如订阅服务、代理费用、大学服务费等)指定不同的domain和payment_category参数,即可实现相应的集成功能,而无需修改PayPal相关的代码。

    进一步阅读资料

Comments are closed.