From 55756744a1a2f56fdb0c4e7a6b0651fe1d75aaf0 Mon Sep 17 00:00:00 2001 From: erio Date: Thu, 5 Mar 2026 01:48:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=AE=9D=E7=94=B5=E8=84=91=E7=BD=91=E7=AB=99=E6=94=AF=E4=BB=98?= =?UTF-8?q?=EF=BC=88alipay=20direct=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/lib/alipay/ 模块:RSA2 签名、网关客户端、AlipayProvider - 新增 /api/alipay/notify 异步通知回调路由 - config.ts 添加 ALIPAY_* 环境变量 - payment/index.ts 注册 alipaydirect 提供商 - 27 个单元测试全部通过 --- src/__tests__/lib/alipay/provider.test.ts | 274 ++++++++++++++++++++++ src/__tests__/lib/alipay/sign.test.ts | 112 +++++++++ src/app/api/alipay/notify/route.ts | 26 ++ src/lib/alipay/client.ts | 100 ++++++++ src/lib/alipay/provider.ts | 121 ++++++++++ src/lib/alipay/sign.ts | 42 ++++ src/lib/alipay/types.ts | 59 +++++ src/lib/config.ts | 7 + src/lib/payment/index.ts | 8 + 9 files changed, 749 insertions(+) create mode 100644 src/__tests__/lib/alipay/provider.test.ts create mode 100644 src/__tests__/lib/alipay/sign.test.ts create mode 100644 src/app/api/alipay/notify/route.ts create mode 100644 src/lib/alipay/client.ts create mode 100644 src/lib/alipay/provider.ts create mode 100644 src/lib/alipay/sign.ts create mode 100644 src/lib/alipay/types.ts diff --git a/src/__tests__/lib/alipay/provider.test.ts b/src/__tests__/lib/alipay/provider.test.ts new file mode 100644 index 0000000..cf8f353 --- /dev/null +++ b/src/__tests__/lib/alipay/provider.test.ts @@ -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', + }); + }); + }); +}); diff --git a/src/__tests__/lib/alipay/sign.test.ts b/src/__tests__/lib/alipay/sign.test.ts new file mode 100644 index 0000000..74c17e8 --- /dev/null +++ b/src/__tests__/lib/alipay/sign.test.ts @@ -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 = { + 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 = {}; + 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); + }); + }); +}); diff --git a/src/app/api/alipay/notify/route.ts b/src/app/api/alipay/notify/route.ts new file mode 100644 index 0000000..70ef012 --- /dev/null +++ b/src/app/api/alipay/notify/route.ts @@ -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 = {}; + 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' }, + }); + } +} diff --git a/src/lib/alipay/client.ts b/src/lib/alipay/client.ts new file mode 100644 index 0000000..099261f --- /dev/null +++ b/src/lib/alipay/client.ts @@ -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 { + 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) { + 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, + options?: { notifyUrl?: string; returnUrl?: string }, +): string { + const env = assertAlipayEnv(getEnv()); + + const params: Record = { + ...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( + method: string, + bizContent: Record, +): Promise { + const env = assertAlipayEnv(getEnv()); + + const params: Record = { + ...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; +} diff --git a/src/lib/alipay/provider.ts b/src/lib/alipay/provider.ts new file mode 100644 index 0000000..f9372a0 --- /dev/null +++ b/src/lib/alipay/provider.ts @@ -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 { + 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 { + const result = await execute('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): Promise { + const env = getEnv(); + const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8'); + const searchParams = new URLSearchParams(body); + + const params: Record = {}; + for (const [key, value] of searchParams.entries()) { + params[key] = value; + } + + const sign = params.sign || ''; + const paramsForVerify: Record = {}; + 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 { + const result = await execute('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 { + await execute('alipay.trade.close', { + out_trade_no: tradeNo, + }); + } +} diff --git a/src/lib/alipay/sign.ts b/src/lib/alipay/sign.ts new file mode 100644 index 0000000..5b51e48 --- /dev/null +++ b/src/lib/alipay/sign.ts @@ -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, 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, 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'); +} diff --git a/src/lib/alipay/types.ts b/src/lib/alipay/types.ts new file mode 100644 index 0000000..4a64c2c --- /dev/null +++ b/src/lib/alipay/types.ts @@ -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; +} diff --git a/src/lib/config.ts b/src/lib/config.ts index f54446b..2f496f6 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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, diff --git a/src/lib/payment/index.ts b/src/lib/payment/index.ts index 469ac64..02df094 100644 --- a/src/lib/payment/index.ts +++ b/src/lib/payment/index.ts @@ -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');