Files
gongxue-base/apps/api/src/features/commerce/providers.ts
2026-06-28 22:42:27 +08:00

421 lines
16 KiB
TypeScript

import crypto from 'node:crypto';
import { HttpError } from '../../core/http.js';
import { config as appConfig } from '../../core/config.js';
import {
optionalPublicBoolean,
optionalPublicString,
providerEndpoint,
requirePublicString,
requireSecretString,
type TenantPaymentProviderConfig,
} from '../../core/tenant-provider-config.js';
export type PaymentProviderName = 'wechat_pay' | 'alipay' | 'manual';
export interface PaymentOrderInput {
tenantId: string;
orderId: string;
orderNo: string;
description: string;
amountCents: number;
userId: string;
openId?: string;
clientIp?: string;
returnUrl?: string;
quitUrl?: string;
}
export interface PaymentCreateResult {
provider: PaymentProviderName;
method: string;
providerTradeNo?: string;
paymentParams: Record<string, unknown>;
raw: Record<string, unknown>;
}
export interface PaymentNotificationResult {
provider: PaymentProviderName;
eventId: string;
eventType: string;
orderNo: string;
providerTradeNo: string;
amountCents: number;
paidAt?: string;
raw: Record<string, unknown>;
}
export interface PaymentProvider {
name: PaymentProviderName;
createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult>;
parseNotification(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
rawBody: string;
}): Promise<PaymentNotificationResult>;
}
function base64urlDecode(value: string) {
return Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function requiredBodyString(body: Record<string, unknown>, key: string, code: string) {
const value = body[key];
if (typeof value === 'string' && value.trim()) return value.trim();
throw new HttpError(400, `${key} is required`, code);
}
function optionalBodyString(body: Record<string, unknown>, key: string) {
const value = body[key];
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function rsaSignSha256(privateKey: string, message: string) {
return crypto.createSign('RSA-SHA256').update(message, 'utf8').sign(privateKey, 'base64');
}
function rsaVerifySha256(publicKey: string, message: string, signature: string) {
return crypto.createVerify('RSA-SHA256').update(message, 'utf8').verify(publicKey, signature, 'base64');
}
function normalizePem(value: string, label: 'PRIVATE KEY' | 'PUBLIC KEY') {
if (value.includes('-----BEGIN')) return value;
const wrapped = value.match(/.{1,64}/g)?.join('\n') || value;
return `-----BEGIN ${label}-----\n${wrapped}\n-----END ${label}-----`;
}
function safeJson(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function canonicalForm(params: Record<string, string>) {
return Object.keys(params)
.filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '')
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
}
function encodedForm(params: Record<string, string>) {
return Object.keys(params)
.filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '')
.sort()
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&');
}
function wechatPrivateKey(config: TenantPaymentProviderConfig) {
return normalizePem(requireSecretString(config, ['privateKey', 'merchantPrivateKey'], 'PAYMENT_SECRET_REQUIRED'), 'PRIVATE KEY');
}
function randomNonce(size = 16) {
return crypto.randomBytes(size).toString('base64url');
}
class WechatPayProvider implements PaymentProvider {
readonly name = 'wechat_pay' as const;
constructor(private readonly config: TenantPaymentProviderConfig) {}
async createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult> {
const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
const mchId = requirePublicString(this.config, ['merchantId', 'mchId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
const merchantSerialNo = requirePublicString(this.config, ['merchantSerialNo'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
const notifyUrl = requirePublicString(this.config, ['notifyUrl'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
const endpoint = providerEndpoint(
this.config,
'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi',
['api.mch.weixin.qq.com'],
'PAYMENT_ENDPOINT_NOT_ALLOWED',
);
const openId = input.openId || optionalPublicString(this.config, ['testOpenId']);
if (!openId) {
throw new HttpError(400, 'openId is required for WeChat JSAPI payment', 'WECHAT_OPENID_REQUIRED');
}
const payload = JSON.stringify({
appid: appId,
mchid: mchId,
description: input.description.slice(0, 120),
out_trade_no: input.orderNo,
notify_url: notifyUrl,
amount: {
total: input.amountCents,
currency: 'CNY',
},
payer: {
openid: openId,
},
});
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomNonce();
const url = new URL(endpoint);
const message = ['POST', `${url.pathname}${url.search}`, timestamp, nonce, payload].join('\n') + '\n';
const signature = rsaSignSha256(wechatPrivateKey(this.config), message);
const response = await fetch(endpoint, {
method: 'POST',
headers: {
accept: 'application/json',
authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${merchantSerialNo}"`,
'content-type': 'application/json',
},
body: payload,
});
const raw = safeJson(await response.json().catch(() => ({})));
if (!response.ok) {
throw new HttpError(502, 'WeChat Pay create transaction failed', 'PAYMENT_PROVIDER_CREATE_FAILED');
}
const prepayId = typeof raw.prepay_id === 'string' ? raw.prepay_id : '';
if (!prepayId) throw new HttpError(502, 'WeChat Pay response is missing prepay_id', 'PAYMENT_PROVIDER_RESPONSE_INVALID');
const payTimestamp = Math.floor(Date.now() / 1000).toString();
const payNonce = randomNonce();
const packageValue = `prepay_id=${prepayId}`;
const paySignMessage = [appId, payTimestamp, payNonce, packageValue].join('\n') + '\n';
const paySign = rsaSignSha256(wechatPrivateKey(this.config), paySignMessage);
return {
provider: this.name,
method: 'jsapi',
providerTradeNo: prepayId,
paymentParams: {
appId,
timeStamp: payTimestamp,
nonceStr: payNonce,
package: packageValue,
signType: 'RSA',
paySign,
},
raw: {
prepayId,
},
};
}
async parseNotification(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
rawBody: string;
}): Promise<PaymentNotificationResult> {
const publicKey = optionalPublicString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey'])
|| requireSecretString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED');
const allowMockSignature = !appConfig.isProduction && optionalPublicBoolean(this.config, 'allowLocalMock', false);
if (publicKey && input.headers['wechatpay-signature']) {
const timestamp = input.headers['wechatpay-timestamp'] || '';
const nonceHeader = input.headers['wechatpay-nonce'] || '';
const message = `${timestamp}\n${nonceHeader}\n${input.rawBody}\n`;
if (!rsaVerifySha256(normalizePem(publicKey, 'PUBLIC KEY'), message, input.headers['wechatpay-signature'])) {
throw new HttpError(400, 'WeChat Pay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID');
}
} else if (!allowMockSignature) {
throw new HttpError(400, 'WeChat Pay notification signature is required', 'PAYMENT_SIGNATURE_REQUIRED');
}
const apiV3Key = requireSecretString(this.config, ['apiV3Key', 'apiv3Key'], 'PAYMENT_SECRET_REQUIRED');
const resource = objectValue(input.body.resource);
const ciphertext = requiredBodyString(resource, 'ciphertext', 'PAYMENT_NOTIFICATION_INVALID');
const nonce = requiredBodyString(resource, 'nonce', 'PAYMENT_NOTIFICATION_INVALID');
const associatedData = optionalBodyString(resource, 'associated_data');
let decrypted: Record<string, unknown>;
try {
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(apiV3Key, 'utf8'), Buffer.from(nonce, 'utf8'));
if (associatedData) decipher.setAAD(Buffer.from(associatedData, 'utf8'));
const encrypted = base64urlDecode(ciphertext);
const authTag = encrypted.subarray(encrypted.length - 16);
const cipherText = encrypted.subarray(0, encrypted.length - 16);
decipher.setAuthTag(authTag);
const buffer = Buffer.concat([decipher.update(cipherText), decipher.final()]);
decrypted = safeJson(JSON.parse(buffer.toString('utf8')));
} catch {
throw new HttpError(400, 'WeChat Pay notification decrypt failed', 'PAYMENT_NOTIFICATION_INVALID');
}
const tradeState = requiredBodyString(decrypted, 'trade_state', 'PAYMENT_NOTIFICATION_INVALID');
if (tradeState !== 'SUCCESS') {
throw new HttpError(409, `WeChat Pay trade state is ${tradeState}`, 'PAYMENT_NOT_PAID');
}
const orderNo = requiredBodyString(decrypted, 'out_trade_no', 'PAYMENT_NOTIFICATION_INVALID');
const providerTradeNo = requiredBodyString(decrypted, 'transaction_id', 'PAYMENT_NOTIFICATION_INVALID');
const amount = objectValue(decrypted.amount);
const amountCents = Number(amount.total);
if (!Number.isInteger(amountCents) || amountCents < 0) {
throw new HttpError(400, 'WeChat Pay notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID');
}
return {
provider: this.name,
eventId: requiredBodyString(input.body, 'id', 'PAYMENT_NOTIFICATION_INVALID'),
eventType: optionalBodyString(input.body, 'event_type') || 'TRANSACTION.SUCCESS',
orderNo,
providerTradeNo,
amountCents,
paidAt: optionalBodyString(decrypted, 'success_time') || undefined,
raw: {
notification: input.body,
transaction: decrypted,
headers: {
serial: input.headers['wechatpay-serial'] || '',
timestamp: input.headers['wechatpay-timestamp'] || '',
},
},
};
}
}
class AlipayProvider implements PaymentProvider {
readonly name = 'alipay' as const;
constructor(private readonly config: TenantPaymentProviderConfig) {}
async createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult> {
const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
const notifyUrl = requirePublicString(this.config, ['notifyUrl'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
const returnUrl = input.returnUrl || optionalPublicString(this.config, ['returnUrl']);
const quitUrl = input.quitUrl || optionalPublicString(this.config, ['quitUrl']);
const gateway = providerEndpoint(
this.config,
'https://openapi.alipay.com/gateway.do',
['openapi.alipay.com'],
'PAYMENT_ENDPOINT_NOT_ALLOWED',
);
const privateKey = normalizePem(requireSecretString(this.config, ['privateKey', 'appPrivateKey'], 'PAYMENT_SECRET_REQUIRED'), 'PRIVATE KEY');
const bizContent: Record<string, unknown> = {
out_trade_no: input.orderNo,
total_amount: (input.amountCents / 100).toFixed(2),
subject: input.description.slice(0, 120),
product_code: 'QUICK_WAP_WAY',
};
if (quitUrl) bizContent.quit_url = quitUrl;
const params: Record<string, string> = {
app_id: appId,
method: 'alipay.trade.wap.pay',
charset: 'utf-8',
sign_type: 'RSA2',
timestamp: new Date().toISOString().replace('T', ' ').slice(0, 19),
version: '1.0',
notify_url: notifyUrl,
biz_content: JSON.stringify(bizContent),
};
if (returnUrl) params.return_url = returnUrl;
params.sign = rsaSignSha256(privateKey, canonicalForm(params));
return {
provider: this.name,
method: 'wap',
paymentParams: {
gateway,
method: 'GET',
query: params,
url: `${gateway}?${encodedForm(params)}`,
},
raw: {
outTradeNo: input.orderNo,
},
};
}
async parseNotification(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
rawBody: string;
}): Promise<PaymentNotificationResult> {
const alipayPublicKey = normalizePem(
requireSecretString(this.config, ['alipayPublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED'),
'PUBLIC KEY',
);
const params: Record<string, string> = {};
for (const [key, value] of Object.entries(input.body)) {
if (typeof value === 'string') params[key] = value;
}
const sign = params.sign || '';
const signType = params.sign_type || 'RSA2';
delete params.sign;
delete params.sign_type;
if (signType !== 'RSA2') throw new HttpError(400, 'Alipay notification must use RSA2', 'PAYMENT_SIGNATURE_INVALID');
if (!rsaVerifySha256(alipayPublicKey, canonicalForm(params), sign)) {
throw new HttpError(400, 'Alipay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID');
}
if (!['TRADE_SUCCESS', 'TRADE_FINISHED'].includes(params.trade_status || '')) {
throw new HttpError(409, `Alipay trade status is ${params.trade_status || 'unknown'}`, 'PAYMENT_NOT_PAID');
}
const totalAmount = Number(params.total_amount || params.receipt_amount);
const amountCents = Math.round(totalAmount * 100);
if (!Number.isInteger(amountCents) || amountCents < 0) {
throw new HttpError(400, 'Alipay notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID');
}
return {
provider: this.name,
eventId: params.notify_id || `${params.trade_no}:${params.trade_status}`,
eventType: params.trade_status || 'TRADE_SUCCESS',
orderNo: params.out_trade_no,
providerTradeNo: params.trade_no,
amountCents,
paidAt: params.gmt_payment || params.notify_time || undefined,
raw: {
notification: input.body,
},
};
}
}
class ManualPaymentProvider implements PaymentProvider {
readonly name = 'manual' as const;
async createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult> {
return {
provider: this.name,
method: 'manual',
paymentParams: {
orderNo: input.orderNo,
amountCents: input.amountCents,
},
raw: {},
};
}
async parseNotification(): Promise<PaymentNotificationResult> {
throw new HttpError(501, 'Manual provider does not support webhook notifications', 'PAYMENT_PROVIDER_NOT_SUPPORTED');
}
}
export function normalizePaymentProvider(value: string): PaymentProviderName {
const normalized = value.toLowerCase().replace(/[-\s]/g, '_');
if (['wechat', 'wechatpay', 'wxpay', 'wx_pay', 'wechat_pay'].includes(normalized)) return 'wechat_pay';
if (['alipay', 'ali_pay'].includes(normalized)) return 'alipay';
return 'manual';
}
export function paymentProviderAliases(value: string) {
const provider = normalizePaymentProvider(value);
if (provider === 'wechat_pay') return ['wechat_pay', 'wechat-pay', 'wechatpay', 'wxpay'];
if (provider === 'alipay') return ['alipay', 'ali_pay'];
return ['manual'];
}
export function createPaymentProvider(
name: string,
config?: TenantPaymentProviderConfig | null,
): PaymentProvider {
const provider = normalizePaymentProvider(name);
if (provider === 'wechat_pay') {
if (!config) throw new HttpError(503, 'WeChat Pay provider is not configured', 'PAYMENT_PROVIDER_NOT_CONFIGURED');
return new WechatPayProvider(config);
}
if (provider === 'alipay') {
if (!config) throw new HttpError(503, 'Alipay provider is not configured', 'PAYMENT_PROVIDER_NOT_CONFIGURED');
return new AlipayProvider(config);
}
return new ManualPaymentProvider();
}
export function allowsLocalMockPayment(config: TenantPaymentProviderConfig | null) {
return config ? optionalPublicBoolean(config, 'allowLocalMock', false) : false;
}