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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user