forked from wangziqi/gongxue-base
421 lines
16 KiB
TypeScript
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;
|
|
}
|