feat: migrate payment provider to easy-pay, add order history and refund support

- Replace zpay with easy-pay payment provider (new lib/easy-pay/ module)
- Add order history page for users (pay/orders)
- Add GET /api/orders/my endpoint to list user's own orders
- Add GET /api/users/[id] endpoint for sub2api user lookup
- Add order status tracking module (lib/order/status.ts)
- Update config to support easy-pay credentials (merchant ID, key, gateway)
- Update PaymentForm and PaymentQRCode components for easy-pay flow
- Update pay page and admin page with new order management UI
- Update order service to support easy-pay, cancellation, and refund
This commit is contained in:
erio
2026-03-01 03:04:24 +08:00
commit d5719bf213
73 changed files with 10616 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { generateRechargeCode } from '@/lib/order/code-gen';
describe('generateRechargeCode', () => {
it('should generate code with s2p_ prefix', () => {
const code = generateRechargeCode('cm1234567890');
expect(code).toBe('s2p_cm1234567890');
});
it('should truncate long order IDs to fit 32 chars', () => {
const longId = 'a'.repeat(50);
const code = generateRechargeCode(longId);
expect(code.length).toBeLessThanOrEqual(32);
expect(code.startsWith('s2p_')).toBe(true);
});
it('should handle empty string', () => {
const code = generateRechargeCode('');
expect(code).toBe('s2p_');
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/config', () => ({
getEnv: () => ({
SUB2API_BASE_URL: 'https://test.sub2api.com',
SUB2API_ADMIN_API_KEY: 'admin-testkey123',
}),
}));
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
describe('Sub2API Client', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('getUser should return user data', async () => {
const mockUser = {
id: 1,
username: 'testuser',
email: 'test@example.com',
status: 'active',
balance: 100,
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: mockUser }),
});
const user = await getUser(1);
expect(user.username).toBe('testuser');
expect(user.status).toBe('active');
});
it('getUser should throw USER_NOT_FOUND for 404', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
});
await expect(getUser(999)).rejects.toThrow('USER_NOT_FOUND');
});
it('createAndRedeem should send correct request', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
code: 1,
redeem_code: {
id: 1,
code: 's2p_test123',
type: 'balance',
value: 100,
status: 'used',
used_by: 1,
},
}),
});
const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes');
expect(result.code).toBe('s2p_test123');
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(fetchCall[0]).toContain('/redeem-codes/create-and-redeem');
const headers = fetchCall[1].headers;
expect(headers['Idempotency-Key']).toBe('sub2apipay:recharge:s2p_test123');
});
it('subtractBalance should send subtract request', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
await subtractBalance(1, 50, 'refund', 'idempotency-key-1');
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(fetchCall[1].body);
expect(body.operation).toBe('subtract');
expect(body.amount).toBe(50);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock config
vi.mock('@/lib/config', () => ({
getEnv: () => ({
ZPAY_PID: 'test_pid',
ZPAY_PKEY: 'test_pkey',
ZPAY_API_BASE: 'https://test.zpay.com',
ZPAY_NOTIFY_URL: 'https://test.com/api/zpay/notify',
ZPAY_RETURN_URL: 'https://test.com/pay/result',
}),
}));
import { createPayment, queryOrder, refund } from '@/lib/zpay/client';
describe('ZPAY Client', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('createPayment should post to mapi.php and return result', async () => {
const mockResponse = {
code: 1,
trade_no: 'zpay_123',
payurl: 'https://pay.example.com',
qrcode: 'https://qr.example.com',
img: 'https://img.example.com/qr.jpg',
};
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve(mockResponse),
});
const result = await createPayment({
outTradeNo: 'test_order_1',
amount: '10.00',
paymentType: 'alipay',
clientIp: '127.0.0.1',
productName: 'Test Product',
});
expect(result.trade_no).toBe('zpay_123');
expect(result.payurl).toBe('https://pay.example.com');
expect(fetch).toHaveBeenCalledWith(
'https://test.zpay.com/mapi.php',
expect.objectContaining({ method: 'POST' }),
);
});
it('createPayment should throw on error response', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ code: 0, msg: 'insufficient balance' }),
});
await expect(
createPayment({
outTradeNo: 'test_order_2',
amount: '10.00',
paymentType: 'alipay',
clientIp: '127.0.0.1',
productName: 'Test Product',
}),
).rejects.toThrow('ZPAY create payment failed');
});
it('queryOrder should fetch order status', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({
code: 1,
trade_no: 'zpay_123',
out_trade_no: 'test_order_1',
status: 1,
money: '10.00',
}),
});
const result = await queryOrder('test_order_1');
expect(result.status).toBe(1);
expect(result.money).toBe('10.00');
});
it('refund should post refund request', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ code: 1, msg: '退款成功' }),
});
const result = await refund('zpay_123', 'test_order_1', '10.00');
expect(result.code).toBe(1);
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { generateSign, verifySign } from '@/lib/zpay/sign';
describe('ZPAY Sign', () => {
const pkey = 'YifxyCWYTLW3hXD4Ae7xB9KqtVA2474k';
it('should generate correct sign with sorted params', () => {
const params = {
pid: '2026022720004756',
type: 'alipay',
out_trade_no: '20160806151343349',
notify_url: 'http://www.aaa.com/notify_url.php',
name: 'test product',
money: '1.00',
return_url: 'http://www.aaa.com/return_url.php',
};
const sign = generateSign(params, pkey);
expect(sign).toMatch(/^[a-f0-9]{32}$/); // md5 lowercase hex
});
it('should filter out empty values, sign and sign_type', () => {
const params = {
a: '1',
b: '',
sign: 'xxx',
sign_type: 'MD5',
c: '3',
};
const sign = generateSign(params, pkey);
// Should only use a=1&c=3 + pkey
const expected = generateSign({ a: '1', c: '3' }, pkey);
expect(sign).toBe(expected);
});
it('should sort params by ASCII order', () => {
const params1 = { z: '1', a: '2', m: '3' };
const params2 = { a: '2', m: '3', z: '1' };
expect(generateSign(params1, pkey)).toBe(generateSign(params2, pkey));
});
it('should verify valid signature', () => {
const params = { a: '1', b: '2' };
const sign = generateSign(params, pkey);
expect(verifySign(params, pkey, sign)).toBe(true);
});
it('should reject invalid signature', () => {
const params = { a: '1', b: '2' };
expect(verifySign(params, pkey, 'invalidsignature1234567890123456')).toBe(false);
});
it('should reject signature with wrong length', () => {
const params = { a: '1', b: '2' };
expect(verifySign(params, pkey, 'short')).toBe(false);
});
});