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,97 @@
import { getEnv } from '@/lib/config';
import { generateSign } from './sign';
import type { EasyPayCreateResponse, EasyPayQueryResponse, EasyPayRefundResponse } from './types';
export interface CreatePaymentOptions {
outTradeNo: string;
amount: string;
paymentType: 'alipay' | 'wxpay';
clientIp: string;
productName: string;
}
function normalizeCidList(cid?: string): string | undefined {
if (!cid) return undefined;
const normalized = cid
.split(',')
.map((item) => item.trim())
.filter(Boolean)
.join(',');
return normalized || undefined;
}
function resolveCid(paymentType: 'alipay' | 'wxpay'): string | undefined {
const env = getEnv();
if (paymentType === 'alipay') {
return normalizeCidList(env.EASY_PAY_CID_ALIPAY) || normalizeCidList(env.EASY_PAY_CID);
}
return normalizeCidList(env.EASY_PAY_CID_WXPAY) || normalizeCidList(env.EASY_PAY_CID);
}
export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPayCreateResponse> {
const env = getEnv();
const params: Record<string, string> = {
pid: env.EASY_PAY_PID,
type: opts.paymentType,
out_trade_no: opts.outTradeNo,
notify_url: env.EASY_PAY_NOTIFY_URL,
return_url: env.EASY_PAY_RETURN_URL,
name: opts.productName,
money: opts.amount,
clientip: opts.clientIp,
};
const cid = resolveCid(opts.paymentType);
if (cid) {
params.cid = cid;
}
const sign = generateSign(params, env.EASY_PAY_PKEY);
params.sign = sign;
params.sign_type = 'MD5';
const formData = new URLSearchParams(params);
const response = await fetch(`${env.EASY_PAY_API_BASE}/mapi.php`, {
method: 'POST',
body: formData,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const data = await response.json() as EasyPayCreateResponse;
if (data.code !== 1) {
throw new Error(`EasyPay create payment failed: ${data.msg || 'unknown error'}`);
}
return data;
}
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
const env = getEnv();
const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`;
const response = await fetch(url);
const data = await response.json() as EasyPayQueryResponse;
if (data.code !== 1) {
throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`);
}
return data;
}
export async function refund(tradeNo: string, outTradeNo: string, money: string): Promise<EasyPayRefundResponse> {
const env = getEnv();
const params = new URLSearchParams({
pid: env.EASY_PAY_PID,
key: env.EASY_PAY_PKEY,
trade_no: tradeNo,
out_trade_no: outTradeNo,
money,
});
const response = await fetch(`${env.EASY_PAY_API_BASE}/api.php?act=refund`, {
method: 'POST',
body: params,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const data = await response.json() as EasyPayRefundResponse;
if (data.code !== 1) {
throw new Error(`EasyPay refund failed: ${data.msg || 'unknown error'}`);
}
return data;
}

19
src/lib/easy-pay/sign.ts Normal file
View File

@@ -0,0 +1,19 @@
import crypto from 'crypto';
export function generateSign(params: Record<string, string>, pkey: 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 queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&');
const signStr = queryString + pkey;
return crypto.createHash('md5').update(signStr).digest('hex');
}
export function verifySign(params: Record<string, string>, pkey: string, sign: string): boolean {
const expected = generateSign(params, pkey);
if (expected.length !== sign.length) return false;
const a = Buffer.from(expected);
const b = Buffer.from(sign);
return crypto.timingSafeEqual(a, b);
}

57
src/lib/easy-pay/types.ts Normal file
View File

@@ -0,0 +1,57 @@
export interface EasyPayCreateParams {
pid: string;
cid?: string;
type: 'alipay' | 'wxpay';
out_trade_no: string;
notify_url: string;
name: string;
money: string;
clientip: string;
return_url: string;
sign?: string;
sign_type?: string;
}
export interface EasyPayCreateResponse {
code: number;
msg?: string;
trade_no: string;
O_id?: string;
payurl?: string;
qrcode?: string;
img?: string;
}
export interface EasyPayNotifyParams {
pid: string;
name: string;
money: string;
out_trade_no: string;
trade_no: string;
param?: string;
trade_status: string;
type: string;
sign: string;
sign_type: string;
}
export interface EasyPayQueryResponse {
code: number;
msg?: string;
trade_no: string;
out_trade_no: string;
type: string;
pid: string;
addtime: string;
endtime: string;
name: string;
money: string;
status: number;
param?: string;
buyer?: string;
}
export interface EasyPayRefundResponse {
code: number;
msg: string;
}