Angular 集成 Square Payment API 和 Firebase serverless 支付
结论
Angular 浏览器端不能直接调用 Square 的付款接口创建 charge。Square Access Token 必须保存在可信环境中。如果把它放进 Angular 代码、环境变量或网络请求,任何访问页面的人都可以将其提取出来。
合适的 serverless 方案如下:
- Angular 通过 Square Web Payments SDK 获取 nonce/payment token。
- Angular 将 token、订单编号和本次支付尝试 ID 发送给 Firebase Cloud Functions。
- Cloud Functions 从数据库读取订单并校验金额。
- Cloud Functions 使用安全保存的 Square Access Token 调用 Payments API。
- Cloud Functions 将支付结果写回 Firestore,再把必要信息返回给 Angular。
Firebase Cloud Functions 仍是服务端,只是无需自行购买和维护服务器,因此很适合用作支付场景中的 serverless 后端。
推荐架构
Angular
│
│ 1. Square SDK 生成 nonce/payment token
▼
Firebase Callable Function
│
│ 2. 验证登录用户、订单、金额和支付状态
│ 3. 从 Secret Manager 读取 Square Access Token
▼
Square Payments API
│
│ 4. 返回支付结果
▼
Firestore / Angular
配置分为两类:
applicationId和locationId可以提供给前端,用于初始化 Square Web Payments SDK。- Square Access Token 只能保存在 Cloud Functions 的 Secret Manager 中,不能写入 Angular 的
environment.ts、Firestore 或前端 HTML。
Angular 端提交支付 token
旧版 Square SDK 通常将前端生成的值称为 nonce,新版 Web Payments SDK 通常称为 payment token。名称不同,但它们都只是支付来源凭证,并不是 Square Access Token。
前端无需再提交隐藏表单,直接调用 Firebase Callable Function:
import { Injectable } from '@angular/core';
import { Functions, httpsCallable } from '@angular/fire/functions';
interface CreatePaymentRequest {
sourceId: string;
orderId: string;
checkoutAttemptId: string;
}
interface CreatePaymentResponse {
paymentId: string;
status: string;
}
@Injectable({ providedIn: 'root' })
export class PaymentService {
constructor(private functions: Functions) {}
async createPayment(
sourceId: string,
orderId: string
): Promise<CreatePaymentResponse> {
const createSquarePayment = httpsCallable<
CreatePaymentRequest,
CreatePaymentResponse
>(this.functions, 'createSquarePayment');
const result = await createSquarePayment({
sourceId,
orderId,
checkoutAttemptId: crypto.randomUUID()
});
return result.data;
}
}
在现有回调中,可以用下面的调用替换表单提交:
cardNonceResponseReceived: async function (
errors: any[],
nonce: string,
cardData: unknown
) {
if (errors) {
errors.forEach(error => console.error(error.message));
return;
}
try {
const result = await this.paymentService.createPayment(
nonce,
this.orderId
);
console.log('Payment result:', result);
} catch (error) {
console.error('Payment failed:', error);
}
}
实际使用时,需要确认回调中的 this 指向 Angular 组件。使用箭头函数会更稳妥,也可以在初始化 Square SDK 时显式绑定组件实例。
如果使用新版 Web Payments SDK,通常先调用卡组件的 tokenize():
async pay(): Promise<void> {
const tokenResult = await this.card.tokenize();
if (tokenResult.status !== 'OK' || !tokenResult.token) {
console.error(tokenResult.errors);
return;
}
const result = await this.paymentService.createPayment(
tokenResult.token,
this.orderId
);
console.log(result);
}
Firebase Cloud Function 创建支付
下面使用 Firebase Functions v2 和 Square REST API 展示主要流程。示例基于以下前提:
- 用户已经通过 Firebase Authentication 登录。
- Firestore 中存在
orders/{orderId}。 - 订单包含可信的
ownerUid、amount、currency和status。 - Cloud Functions 运行时支持原生
fetch。 - Square API 版本由部署配置固定,示例中的占位符不能直接用于生产环境。
import { initializeApp } from 'firebase-admin/app';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { defineSecret, defineString } from 'firebase-functions/params';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
initializeApp();
const db = getFirestore();
const squareAccessToken = defineSecret('SQUARE_ACCESS_TOKEN');
const squareLocationId = defineString('SQUARE_LOCATION_ID');
const squareApiVersion = defineString('SQUARE_API_VERSION');
const squareEnvironment = defineString('SQUARE_ENVIRONMENT');
export const createSquarePayment = onCall(
{
secrets: [squareAccessToken],
enforceAppCheck: true
},
async request => {
if (!request.auth) {
throw new HttpsError(
'unauthenticated',
'You must sign in before making a payment.'
);
}
const sourceId = request.data?.sourceId;
const orderId = request.data?.orderId;
const checkoutAttemptId = request.data?.checkoutAttemptId;
if (
typeof sourceId !== 'string' ||
typeof orderId !== 'string' ||
typeof checkoutAttemptId !== 'string'
) {
throw new HttpsError(
'invalid-argument',
'Invalid payment request.'
);
}
const orderRef = db.collection('orders').doc(orderId);
const orderSnapshot = await orderRef.get();
if (!orderSnapshot.exists) {
throw new HttpsError('not-found', 'Order does not exist.');
}
const order = orderSnapshot.data()!;
if (order.ownerUid !== request.auth.uid) {
throw new HttpsError(
'permission-denied',
'You cannot pay for this order.'
);
}
if (order.status === 'paid') {
return {
paymentId: order.squarePaymentId,
status: 'COMPLETED'
};
}
if (
!Number.isInteger(order.amount) ||
order.amount <= 0 ||
typeof order.currency !== 'string'
) {
throw new HttpsError(
'failed-precondition',
'Order amount is invalid.'
);
}
const baseUrl =
squareEnvironment.value() === 'production'
? 'https://connect.squareup.com'
: 'https://connect.squareupsandbox.com';
const squareResponse = await fetch(`${baseUrl}/v2/payments`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${squareAccessToken.value()}`,
'Content-Type': 'application/json',
'Square-Version': squareApiVersion.value()
},
body: JSON.stringify({
source_id: sourceId,
idempotency_key: checkoutAttemptId,
amount_money: {
amount: order.amount,
currency: order.currency
},
location_id: squareLocationId.value(),
reference_id: orderId,
autocomplete: true
})
});
const squareResult = await squareResponse.json();
if (!squareResponse.ok || !squareResult.payment) {
console.error('Square payment error', {
orderId,
errors: squareResult.errors
});
throw new HttpsError(
'failed-precondition',
'The payment could not be completed.'
);
}
const payment = squareResult.payment;
await orderRef.update({
status:
payment.status === 'COMPLETED'
? 'paid'
: 'payment_processing',
squarePaymentId: payment.id,
squarePaymentStatus: payment.status,
paidAt:
payment.status === 'COMPLETED'
? FieldValue.serverTimestamp()
: null,
updatedAt: FieldValue.serverTimestamp()
});
return {
paymentId: payment.id,
status: payment.status
};
}
);
Square SDK 的包结构和方法签名可能随版本变化,所以示例直接调用 /v2/payments,以免混用不同版本的 SDK。生产环境应在配置中固定已经验证的 Square-Version,不要随意填写未经确认的版本号。
配置密钥
通过 Firebase Secret Manager 配置 Access Token:
firebase functions:secrets:set SQUARE_ACCESS_TOKEN
非敏感配置可以按照项目采用的 Firebase Functions 配置方式提供,例如:
SQUARE_LOCATION_ID
SQUARE_API_VERSION
SQUARE_ENVIRONMENT
可以约定 SQUARE_ENVIRONMENT 的值为 sandbox 或 production。沙箱 token 必须配合沙箱 API 地址使用,生产 token 则必须配合生产 API 地址。
部署函数时可以执行:
firebase deploy --only functions:createSquarePayment
为什么金额不能从 Angular 直接传入
下面的设计并不安全:
createPayment({
sourceId: nonce,
amount: 100
});
用户可以修改浏览器发出的请求。如果后端直接采用请求中的 amount,攻击者可能把订单金额从 10000 改成 1。
前端只应提交订单 ID。Cloud Function 收到请求后,必须根据订单 ID 从 Firestore、商品表或服务端订单记录中重新计算金额:
const order = await loadOrder(orderId);
const amount = calculateTrustedAmount(order);
Square 的 amount_money.amount 通常使用货币最小单位的整数。例如,美元使用分,而不是浮点数:
amount_money: {
amount: 1999,
currency: 'USD'
}
这段配置表示 19.99 USD。某种货币是否有特殊的最小单位规则,需要结合订单货币和 Square 账户所在市场处理。
必须处理幂等性
网络超时、用户重复点击或客户端自动重试,都可能导致同一笔付款请求被发送多次。idempotency_key 可以防止同一次支付尝试造成重复扣款。
前端应为一次结账尝试生成一个稳定的 UUID:
const checkoutAttemptId = crypto.randomUUID();
重试同一次请求时,必须继续使用原来的值,不能在每次重试时重新生成。后端还应保存该值,并将它与 orderId、uid 绑定,防止同一个标识被用于不同订单。
较完整的实现可以创建:
paymentAttempts/{checkoutAttemptId}
其中记录:
{
uid,
orderId,
status,
squarePaymentId,
createdAt
}
Square 的幂等性机制用于避免重复创建付款,Firestore 中的记录用于管理业务状态和查询支付结果。
安全注意事项
- 不要在 Angular 中保存 Square Access Token,包括
environment.prod.ts。Angular 环境配置最终会被打包进浏览器 bundle,无法保密。 - 不要将 Access Token 写入 Firestore。即使已经配置 Security Rules,也没有必要承担这种风险。
- 不要长期保存 nonce/payment token。获取后应立即发送给 Cloud Function;如果请求失败,应按照 Square SDK 的流程重新生成。
- 必须校验 Firebase Authentication 身份、订单所有者、订单状态、金额和货币。
- 建议启用 Firebase App Check。设置
enforceAppCheck: true之前,应先完成 Angular 客户端配置,否则合法请求也会遭到拒绝。 - Firestore Security Rules 无法替代 Cloud Function 中的业务校验。客户端不应有权限直接把订单状态改成
paid。 - 不要把 Square 返回的完整错误对象直接发给浏览器。服务端可以记录诊断信息,客户端只接收适合展示给用户的提示。
- 前端显示”付款成功”之前,应检查 Square 返回的实际支付状态。如果状态不是
COMPLETED,需要继续查询,或通过 Square Webhook 更新最终结果。 - Webhook 必须验证 Square 提供的签名,不能只根据请求正文修改订单状态。
- 对于金额较高或库存敏感的订单,最好先锁定订单状态,再调用 Square,并结合幂等键和支付尝试记录处理并发请求。
所以,“serverless”并不等于完全没有后端代码,而是由 Firebase Cloud Functions 托管可信后端。Angular 负责安全采集付款信息并生成 token,真正创建付款的操作必须在 Cloud Function 中完成。
备注:内容仅供参考。