feat: 集成支付宝电脑网站支付(alipay direct)
- 新增 src/lib/alipay/ 模块:RSA2 签名、网关客户端、AlipayProvider - 新增 /api/alipay/notify 异步通知回调路由 - config.ts 添加 ALIPAY_* 环境变量 - payment/index.ts 注册 alipaydirect 提供商 - 27 个单元测试全部通过
This commit is contained in:
100
src/lib/alipay/client.ts
Normal file
100
src/lib/alipay/client.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { generateSign } from './sign';
|
||||
import type { AlipayResponse } from './types';
|
||||
|
||||
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
|
||||
|
||||
function getCommonParams(appId: string): Record<string, string> {
|
||||
return {
|
||||
app_id: appId,
|
||||
format: 'JSON',
|
||||
charset: 'utf-8',
|
||||
sign_type: 'RSA2',
|
||||
timestamp: new Date().toISOString().replace('T', ' ').substring(0, 19),
|
||||
version: '1.0',
|
||||
};
|
||||
}
|
||||
|
||||
function assertAlipayEnv(env: ReturnType<typeof getEnv>) {
|
||||
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY || !env.ALIPAY_PUBLIC_KEY) {
|
||||
throw new Error(
|
||||
'Alipay environment variables (ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY, ALIPAY_PUBLIC_KEY) are required',
|
||||
);
|
||||
}
|
||||
return env as typeof env & {
|
||||
ALIPAY_APP_ID: string;
|
||||
ALIPAY_PRIVATE_KEY: string;
|
||||
ALIPAY_PUBLIC_KEY: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成电脑网站支付的跳转 URL(GET 方式)
|
||||
* 用于 alipay.trade.page.pay
|
||||
*/
|
||||
export function pageExecute(
|
||||
bizContent: Record<string, unknown>,
|
||||
options?: { notifyUrl?: string; returnUrl?: string },
|
||||
): string {
|
||||
const env = assertAlipayEnv(getEnv());
|
||||
|
||||
const params: Record<string, string> = {
|
||||
...getCommonParams(env.ALIPAY_APP_ID),
|
||||
method: 'alipay.trade.page.pay',
|
||||
biz_content: JSON.stringify(bizContent),
|
||||
};
|
||||
|
||||
if (options?.notifyUrl || env.ALIPAY_NOTIFY_URL) {
|
||||
params.notify_url = (options?.notifyUrl || env.ALIPAY_NOTIFY_URL)!;
|
||||
}
|
||||
if (options?.returnUrl || env.ALIPAY_RETURN_URL) {
|
||||
params.return_url = (options?.returnUrl || env.ALIPAY_RETURN_URL)!;
|
||||
}
|
||||
|
||||
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
|
||||
|
||||
const query = new URLSearchParams({ ...params, sign_type: 'RSA2' }).toString();
|
||||
return `${GATEWAY}?${query}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用支付宝服务端 API(POST 方式)
|
||||
* 用于 alipay.trade.query、alipay.trade.refund、alipay.trade.close
|
||||
*/
|
||||
export async function execute<T extends AlipayResponse>(
|
||||
method: string,
|
||||
bizContent: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const env = assertAlipayEnv(getEnv());
|
||||
|
||||
const params: Record<string, string> = {
|
||||
...getCommonParams(env.ALIPAY_APP_ID),
|
||||
method,
|
||||
biz_content: JSON.stringify(bizContent),
|
||||
};
|
||||
|
||||
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
|
||||
params.sign_type = 'RSA2';
|
||||
|
||||
const response = await fetch(GATEWAY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(params).toString(),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
|
||||
const responseKey = method.replace(/\./g, '_') + '_response';
|
||||
const result = data[responseKey] as T;
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`Alipay API error: unexpected response format for ${method}`);
|
||||
}
|
||||
|
||||
if (result.code !== '10000') {
|
||||
throw new Error(`Alipay API error: [${result.sub_code || result.code}] ${result.sub_msg || result.msg}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
121
src/lib/alipay/provider.ts
Normal file
121
src/lib/alipay/provider.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
PaymentProvider,
|
||||
PaymentType,
|
||||
CreatePaymentRequest,
|
||||
CreatePaymentResponse,
|
||||
QueryOrderResponse,
|
||||
PaymentNotification,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
} from '@/lib/payment/types';
|
||||
import { pageExecute, execute } from './client';
|
||||
import { verifySign } from './sign';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { AlipayTradeQueryResponse, AlipayTradeRefundResponse, AlipayTradeCloseResponse } from './types';
|
||||
|
||||
export class AlipayProvider implements PaymentProvider {
|
||||
readonly name = 'alipay-direct';
|
||||
readonly providerKey = 'alipaydirect';
|
||||
readonly supportedTypes: PaymentType[] = ['alipay'];
|
||||
readonly defaultLimits = {
|
||||
alipay: { singleMax: 1000, dailyMax: 10000 },
|
||||
};
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const url = pageExecute(
|
||||
{
|
||||
out_trade_no: request.orderId,
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||
total_amount: request.amount.toFixed(2),
|
||||
subject: request.subject,
|
||||
},
|
||||
{
|
||||
notifyUrl: request.notifyUrl,
|
||||
returnUrl: request.returnUrl,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tradeNo: request.orderId,
|
||||
payUrl: url,
|
||||
};
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
const result = await execute<AlipayTradeQueryResponse>('alipay.trade.query', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
|
||||
let status: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||
switch (result.trade_status) {
|
||||
case 'TRADE_SUCCESS':
|
||||
case 'TRADE_FINISHED':
|
||||
status = 'paid';
|
||||
break;
|
||||
case 'TRADE_CLOSED':
|
||||
status = 'failed';
|
||||
break;
|
||||
default:
|
||||
status = 'pending';
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: result.trade_no || tradeNo,
|
||||
status,
|
||||
amount: parseFloat(result.total_amount || '0'),
|
||||
paidAt: result.send_pay_date ? new Date(result.send_pay_date) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyNotification(rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
|
||||
const env = getEnv();
|
||||
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
|
||||
const searchParams = new URLSearchParams(body);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
const sign = params.sign || '';
|
||||
const paramsForVerify: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (key !== 'sign' && key !== 'sign_type' && value !== undefined && value !== null) {
|
||||
paramsForVerify[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(paramsForVerify, env.ALIPAY_PUBLIC_KEY, sign)) {
|
||||
throw new Error('Alipay notification signature verification failed');
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: params.trade_no || '',
|
||||
orderId: params.out_trade_no || '',
|
||||
amount: parseFloat(params.total_amount || '0'),
|
||||
status: params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED'
|
||||
? 'success'
|
||||
: 'failed',
|
||||
rawData: params,
|
||||
};
|
||||
}
|
||||
|
||||
async refund(request: RefundRequest): Promise<RefundResponse> {
|
||||
const result = await execute<AlipayTradeRefundResponse>('alipay.trade.refund', {
|
||||
out_trade_no: request.orderId,
|
||||
refund_amount: request.amount.toFixed(2),
|
||||
refund_reason: request.reason || '',
|
||||
});
|
||||
|
||||
return {
|
||||
refundId: result.trade_no || `${request.orderId}-refund`,
|
||||
status: result.fund_change === 'Y' ? 'success' : 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
async cancelPayment(tradeNo: string): Promise<void> {
|
||||
await execute<AlipayTradeCloseResponse>('alipay.trade.close', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
}
|
||||
}
|
||||
42
src/lib/alipay/sign.ts
Normal file
42
src/lib/alipay/sign.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/** 自动补全 PEM 格式(PKCS8) */
|
||||
function formatPrivateKey(key: string): string {
|
||||
if (key.includes('-----BEGIN')) return key;
|
||||
return `-----BEGIN PRIVATE KEY-----\n${key}\n-----END PRIVATE KEY-----`;
|
||||
}
|
||||
|
||||
function formatPublicKey(key: string): string {
|
||||
if (key.includes('-----BEGIN')) return key;
|
||||
return `-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC KEY-----`;
|
||||
}
|
||||
|
||||
/** 生成 RSA2 签名 */
|
||||
export function generateSign(params: Record<string, string>, privateKey: string): string {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(
|
||||
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
|
||||
const signer = crypto.createSign('RSA-SHA256');
|
||||
signer.update(signStr);
|
||||
return signer.sign(formatPrivateKey(privateKey), 'base64');
|
||||
}
|
||||
|
||||
/** 用支付宝公钥验证签名 */
|
||||
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(
|
||||
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(signStr);
|
||||
return verifier.verify(formatPublicKey(alipayPublicKey), sign, 'base64');
|
||||
}
|
||||
59
src/lib/alipay/types.ts
Normal file
59
src/lib/alipay/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** 支付宝电脑网站支付 bizContent */
|
||||
export interface AlipayTradePagePayBizContent {
|
||||
out_trade_no: string;
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY';
|
||||
total_amount: string;
|
||||
subject: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/** 支付宝统一响应结构 */
|
||||
export interface AlipayResponse {
|
||||
code: string;
|
||||
msg: string;
|
||||
sub_code?: string;
|
||||
sub_msg?: string;
|
||||
}
|
||||
|
||||
/** alipay.trade.query 响应 */
|
||||
export interface AlipayTradeQueryResponse extends AlipayResponse {
|
||||
trade_no?: string;
|
||||
out_trade_no?: string;
|
||||
trade_status?: string; // WAIT_BUYER_PAY, TRADE_CLOSED, TRADE_SUCCESS, TRADE_FINISHED
|
||||
total_amount?: string;
|
||||
send_pay_date?: string;
|
||||
}
|
||||
|
||||
/** alipay.trade.refund 响应 */
|
||||
export interface AlipayTradeRefundResponse extends AlipayResponse {
|
||||
trade_no?: string;
|
||||
out_trade_no?: string;
|
||||
refund_fee?: string;
|
||||
fund_change?: string; // Y/N
|
||||
}
|
||||
|
||||
/** alipay.trade.close 响应 */
|
||||
export interface AlipayTradeCloseResponse extends AlipayResponse {
|
||||
trade_no?: string;
|
||||
out_trade_no?: string;
|
||||
}
|
||||
|
||||
/** 异步通知参数 */
|
||||
export interface AlipayNotifyParams {
|
||||
notify_time: string;
|
||||
notify_type: string;
|
||||
notify_id: string;
|
||||
app_id: string;
|
||||
charset: string;
|
||||
version: string;
|
||||
sign_type: string;
|
||||
sign: string;
|
||||
trade_no: string;
|
||||
out_trade_no: string;
|
||||
trade_status: string;
|
||||
total_amount: string;
|
||||
receipt_amount?: string;
|
||||
buyer_pay_amount?: string;
|
||||
gmt_payment?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
@@ -28,6 +28,13 @@ const envSchema = z.object({
|
||||
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
|
||||
EASY_PAY_CID_WXPAY: optionalTrimmedString,
|
||||
|
||||
// ── 支付宝直连(PAYMENT_PROVIDERS 含 alipaydirect 时必填) ──
|
||||
ALIPAY_APP_ID: optionalTrimmedString,
|
||||
ALIPAY_PRIVATE_KEY: optionalTrimmedString,
|
||||
ALIPAY_PUBLIC_KEY: optionalTrimmedString,
|
||||
ALIPAY_NOTIFY_URL: optionalTrimmedString,
|
||||
ALIPAY_RETURN_URL: optionalTrimmedString,
|
||||
|
||||
// ── Stripe(PAYMENT_PROVIDERS 含 stripe 时必填) ──
|
||||
STRIPE_SECRET_KEY: optionalTrimmedString,
|
||||
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { paymentRegistry } from './registry';
|
||||
import type { PaymentType } from './types';
|
||||
import { EasyPayProvider } from '@/lib/easy-pay/provider';
|
||||
import { StripeProvider } from '@/lib/stripe/provider';
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
export { paymentRegistry } from './registry';
|
||||
@@ -31,6 +32,13 @@ export function initPaymentProviders(): void {
|
||||
paymentRegistry.register(new EasyPayProvider());
|
||||
}
|
||||
|
||||
if (providers.includes('alipaydirect')) {
|
||||
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY) {
|
||||
throw new Error('PAYMENT_PROVIDERS 含 alipaydirect,但缺少 ALIPAY_APP_ID 或 ALIPAY_PRIVATE_KEY');
|
||||
}
|
||||
paymentRegistry.register(new AlipayProvider());
|
||||
}
|
||||
|
||||
if (providers.includes('stripe')) {
|
||||
if (!env.STRIPE_SECRET_KEY) {
|
||||
throw new Error('PAYMENT_PROVIDERS 含 stripe,但缺少 STRIPE_SECRET_KEY');
|
||||
|
||||
Reference in New Issue
Block a user