feat: 集成支付宝电脑网站支付(alipay direct)
- 新增 src/lib/alipay/ 模块:RSA2 签名、网关客户端、AlipayProvider - 新增 /api/alipay/notify 异步通知回调路由 - config.ts 添加 ALIPAY_* 环境变量 - payment/index.ts 注册 alipaydirect 提供商 - 27 个单元测试全部通过
This commit is contained in:
274
src/__tests__/lib/alipay/provider.test.ts
Normal file
274
src/__tests__/lib/alipay/provider.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
ALIPAY_APP_ID: '2021000000000000',
|
||||
ALIPAY_PRIVATE_KEY: 'test-private-key',
|
||||
ALIPAY_PUBLIC_KEY: 'test-public-key',
|
||||
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
|
||||
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockPageExecute = vi.fn();
|
||||
const mockExecute = vi.fn();
|
||||
|
||||
vi.mock('@/lib/alipay/client', () => ({
|
||||
pageExecute: (...args: unknown[]) => mockPageExecute(...args),
|
||||
execute: (...args: unknown[]) => mockExecute(...args),
|
||||
}));
|
||||
|
||||
const mockVerifySign = vi.fn();
|
||||
|
||||
vi.mock('@/lib/alipay/sign', () => ({
|
||||
verifySign: (...args: unknown[]) => mockVerifySign(...args),
|
||||
}));
|
||||
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types';
|
||||
|
||||
describe('AlipayProvider', () => {
|
||||
let provider: AlipayProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
provider = new AlipayProvider();
|
||||
});
|
||||
|
||||
describe('metadata', () => {
|
||||
it('should have name "alipay-direct"', () => {
|
||||
expect(provider.name).toBe('alipay-direct');
|
||||
});
|
||||
|
||||
it('should have providerKey "alipaydirect"', () => {
|
||||
expect(provider.providerKey).toBe('alipaydirect');
|
||||
});
|
||||
|
||||
it('should support "alipay" payment type', () => {
|
||||
expect(provider.supportedTypes).toEqual(['alipay']);
|
||||
});
|
||||
|
||||
it('should have default limits', () => {
|
||||
expect(provider.defaultLimits).toEqual({
|
||||
alipay: { singleMax: 1000, dailyMax: 10000 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should call pageExecute and return payUrl', async () => {
|
||||
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-001',
|
||||
amount: 100,
|
||||
paymentType: 'alipay',
|
||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
||||
clientIp: '127.0.0.1',
|
||||
};
|
||||
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('order-001');
|
||||
expect(result.payUrl).toBe('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
expect(mockPageExecute).toHaveBeenCalledWith(
|
||||
{
|
||||
out_trade_no: 'order-001',
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||
total_amount: '100.00',
|
||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
||||
},
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryOrder', () => {
|
||||
it('should return paid status for TRADE_SUCCESS', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500001',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '100.00',
|
||||
send_pay_date: '2026-03-05 12:00:00',
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('order-001');
|
||||
expect(result.tradeNo).toBe('2026030500001');
|
||||
expect(result.status).toBe('paid');
|
||||
expect(result.amount).toBe(100);
|
||||
expect(result.paidAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should return paid status for TRADE_FINISHED', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500002',
|
||||
trade_status: 'TRADE_FINISHED',
|
||||
total_amount: '50.00',
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('order-002');
|
||||
expect(result.status).toBe('paid');
|
||||
});
|
||||
|
||||
it('should return pending status for WAIT_BUYER_PAY', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500003',
|
||||
trade_status: 'WAIT_BUYER_PAY',
|
||||
total_amount: '30.00',
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('order-003');
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('should return failed status for TRADE_CLOSED', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500004',
|
||||
trade_status: 'TRADE_CLOSED',
|
||||
total_amount: '20.00',
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('order-004');
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyNotification', () => {
|
||||
it('should verify and parse successful payment notification', async () => {
|
||||
mockVerifySign.mockReturnValue(true);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500001',
|
||||
out_trade_no: 'order-001',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '100.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000000000',
|
||||
}).toString();
|
||||
|
||||
const result = await provider.verifyNotification(body, {});
|
||||
|
||||
expect(result.tradeNo).toBe('2026030500001');
|
||||
expect(result.orderId).toBe('order-001');
|
||||
expect(result.amount).toBe(100);
|
||||
expect(result.status).toBe('success');
|
||||
});
|
||||
|
||||
it('should parse TRADE_FINISHED as success', async () => {
|
||||
mockVerifySign.mockReturnValue(true);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500002',
|
||||
out_trade_no: 'order-002',
|
||||
trade_status: 'TRADE_FINISHED',
|
||||
total_amount: '50.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
}).toString();
|
||||
|
||||
const result = await provider.verifyNotification(body, {});
|
||||
expect(result.status).toBe('success');
|
||||
});
|
||||
|
||||
it('should parse TRADE_CLOSED as failed', async () => {
|
||||
mockVerifySign.mockReturnValue(true);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500003',
|
||||
out_trade_no: 'order-003',
|
||||
trade_status: 'TRADE_CLOSED',
|
||||
total_amount: '30.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
}).toString();
|
||||
|
||||
const result = await provider.verifyNotification(body, {});
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should throw on invalid signature', async () => {
|
||||
mockVerifySign.mockReturnValue(false);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500004',
|
||||
out_trade_no: 'order-004',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '20.00',
|
||||
sign: 'bad_sign',
|
||||
sign_type: 'RSA2',
|
||||
}).toString();
|
||||
|
||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow(
|
||||
'Alipay notification signature verification failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refund', () => {
|
||||
it('should call alipay.trade.refund and return success', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500001',
|
||||
fund_change: 'Y',
|
||||
});
|
||||
|
||||
const request: RefundRequest = {
|
||||
tradeNo: '2026030500001',
|
||||
orderId: 'order-001',
|
||||
amount: 100,
|
||||
reason: 'customer request',
|
||||
};
|
||||
|
||||
const result = await provider.refund(request);
|
||||
expect(result.refundId).toBe('2026030500001');
|
||||
expect(result.status).toBe('success');
|
||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.refund', {
|
||||
out_trade_no: 'order-001',
|
||||
refund_amount: '100.00',
|
||||
refund_reason: 'customer request',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return pending when fund_change is N', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500002',
|
||||
fund_change: 'N',
|
||||
});
|
||||
|
||||
const result = await provider.refund({
|
||||
tradeNo: '2026030500002',
|
||||
orderId: 'order-002',
|
||||
amount: 50,
|
||||
});
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelPayment', () => {
|
||||
it('should call alipay.trade.close', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500001',
|
||||
});
|
||||
|
||||
await provider.cancelPayment('order-001');
|
||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
|
||||
out_trade_no: 'order-001',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
112
src/__tests__/lib/alipay/sign.test.ts
Normal file
112
src/__tests__/lib/alipay/sign.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import crypto from 'crypto';
|
||||
import { generateSign, verifySign } from '@/lib/alipay/sign';
|
||||
|
||||
// 生成测试用 RSA 密钥对
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
// 提取裸 base64(去掉 PEM 头尾)
|
||||
const barePrivateKey = privateKey
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||
.replace(/-----END PRIVATE KEY-----/, '')
|
||||
.replace(/\n/g, '');
|
||||
const barePublicKey = publicKey
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/, '')
|
||||
.replace(/-----END PUBLIC KEY-----/, '')
|
||||
.replace(/\n/g, '');
|
||||
|
||||
describe('Alipay RSA2 Sign', () => {
|
||||
const testParams: Record<string, string> = {
|
||||
app_id: '2021000000000000',
|
||||
method: 'alipay.trade.page.pay',
|
||||
charset: 'utf-8',
|
||||
timestamp: '2026-03-05 12:00:00',
|
||||
version: '1.0',
|
||||
biz_content: '{"out_trade_no":"order-001","total_amount":"100.00"}',
|
||||
};
|
||||
|
||||
describe('generateSign', () => {
|
||||
it('should generate a valid RSA2 signature', () => {
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
expect(sign).toBeTruthy();
|
||||
expect(typeof sign).toBe('string');
|
||||
// base64 格式
|
||||
expect(() => Buffer.from(sign, 'base64')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should produce consistent signatures for same input', () => {
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(testParams, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should filter out sign and sign_type fields', () => {
|
||||
const paramsWithSign = { ...testParams, sign: 'old_sign', sign_type: 'RSA2' };
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(paramsWithSign, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should filter out empty values', () => {
|
||||
const paramsWithEmpty = { ...testParams, empty_field: '' };
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(paramsWithEmpty, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should sort parameters alphabetically', () => {
|
||||
const reversed: Record<string, string> = {};
|
||||
const keys = Object.keys(testParams).reverse();
|
||||
for (const key of keys) {
|
||||
reversed[key] = testParams[key];
|
||||
}
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(reversed, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySign', () => {
|
||||
it('should verify a valid signature', () => {
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
const valid = verifySign(testParams, publicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject an invalid signature', () => {
|
||||
const valid = verifySign(testParams, publicKey, 'invalid_base64_signature');
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject tampered params', () => {
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
const tampered = { ...testParams, total_amount: '999.99' };
|
||||
const valid = verifySign(tampered, publicKey, sign);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PEM auto-formatting', () => {
|
||||
it('should work with bare base64 private key (no PEM headers)', () => {
|
||||
const sign = generateSign(testParams, barePrivateKey);
|
||||
const valid = verifySign(testParams, publicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with bare base64 public key (no PEM headers)', () => {
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
const valid = verifySign(testParams, barePublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with both bare keys', () => {
|
||||
const sign = generateSign(testParams, barePrivateKey);
|
||||
const valid = verifySign(testParams, barePublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
src/app/api/alipay/notify/route.ts
Normal file
26
src/app/api/alipay/notify/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handlePaymentNotify } from '@/lib/order/service';
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
|
||||
const alipayProvider = new AlipayProvider();
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const rawBody = await request.text();
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
const notification = await alipayProvider.verifyNotification(rawBody, headers);
|
||||
const success = await handlePaymentNotify(notification, alipayProvider.name);
|
||||
return new Response(success ? 'success' : 'fail', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Alipay notify error:', error);
|
||||
return new Response('fail', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
}
|
||||
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