refactor: extract pay page components and migrate zpay → easypay

Components:
- Add PayPageLayout, OrderFilterBar, MobileOrderList, OrderTable, OrderSummaryCards
- Extract shared pay-utils (types, constants, helper functions)
- Simplify pay/page.tsx and orders/page.tsx

EasyPay migration:
- Remove src/lib/zpay/, api/zpay/notify, zpay test, zpay.md
- Simplify config.ts: single envSchema, no ZPAY_* fallback
- Rename DB field zpay_trade_no → payment_trade_no (migration added)
- Update OrderDetail label: ZPAY订单号 → 支付单号
- Update CLAUDE.md project structure
This commit is contained in:
erio
2026-03-01 15:55:43 +08:00
parent d2e856b89c
commit 2f45044073
18 changed files with 548 additions and 965 deletions

View File

@@ -6,30 +6,21 @@ const optionalTrimmedString = z.preprocess((value) => {
return trimmed === '' ? undefined : trimmed;
}, z.string().optional());
const rawEnvSchema = z.object({
const envSchema = z.object({
DATABASE_URL: z.string().min(1),
SUB2API_BASE_URL: z.string().url(),
SUB2API_ADMIN_API_KEY: z.string().min(1),
EASY_PAY_PID: optionalTrimmedString,
EASY_PAY_PKEY: optionalTrimmedString,
EASY_PAY_API_BASE: optionalTrimmedString,
EASY_PAY_NOTIFY_URL: optionalTrimmedString,
EASY_PAY_RETURN_URL: optionalTrimmedString,
EASY_PAY_PID: z.string().min(1),
EASY_PAY_PKEY: z.string().min(1),
EASY_PAY_API_BASE: z.string().url(),
EASY_PAY_NOTIFY_URL: z.string().url(),
EASY_PAY_RETURN_URL: z.string().url(),
EASY_PAY_CID: optionalTrimmedString,
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
EASY_PAY_CID_WXPAY: optionalTrimmedString,
ZPAY_PID: optionalTrimmedString,
ZPAY_PKEY: optionalTrimmedString,
ZPAY_API_BASE: optionalTrimmedString,
ZPAY_NOTIFY_URL: optionalTrimmedString,
ZPAY_RETURN_URL: optionalTrimmedString,
ZPAY_CID: optionalTrimmedString,
ZPAY_CID_ALIPAY: optionalTrimmedString,
ZPAY_CID_WXPAY: optionalTrimmedString,
ENABLED_PAYMENT_TYPES: z.string().default('alipay,wxpay').transform(v => v.split(',').map(s => s.trim())),
ORDER_TIMEOUT_MINUTES: z.string().default('5').transform(Number).pipe(z.number().int().positive()),
@@ -44,96 +35,19 @@ const rawEnvSchema = z.object({
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
});
const resolvedEnvSchema = z.object({
DATABASE_URL: z.string().min(1),
SUB2API_BASE_URL: z.string().url(),
SUB2API_ADMIN_API_KEY: z.string().min(1),
EASY_PAY_PID: z.string().min(1),
EASY_PAY_PKEY: z.string().min(1),
EASY_PAY_API_BASE: z.string().url(),
EASY_PAY_NOTIFY_URL: z.string().url(),
EASY_PAY_RETURN_URL: z.string().url(),
EASY_PAY_CID: optionalTrimmedString,
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
EASY_PAY_CID_WXPAY: optionalTrimmedString,
ENABLED_PAYMENT_TYPES: z.array(z.string()),
ORDER_TIMEOUT_MINUTES: z.number().int().positive(),
MIN_RECHARGE_AMOUNT: z.number().positive(),
MAX_RECHARGE_AMOUNT: z.number().positive(),
PRODUCT_NAME: z.string(),
ADMIN_TOKEN: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: optionalTrimmedString,
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
});
export type Env = z.infer<typeof resolvedEnvSchema>;
type RawEnv = z.infer<typeof rawEnvSchema>;
function pickRequired(raw: RawEnv, key: keyof RawEnv, fallbackKey: keyof RawEnv): string {
const value = raw[key] ?? raw[fallbackKey];
if (!value) {
throw new Error(`Missing required env: ${String(key)} (fallback: ${String(fallbackKey)})`);
}
return value as string;
}
function pickOptional(raw: RawEnv, key: keyof RawEnv, fallbackKey: keyof RawEnv): string | undefined {
return (raw[key] ?? raw[fallbackKey] ?? undefined) as string | undefined;
}
export type Env = z.infer<typeof envSchema>;
let cachedEnv: Env | null = null;
export function getEnv(): Env {
if (cachedEnv) return cachedEnv;
const parsed = rawEnvSchema.safeParse(process.env);
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
throw new Error('Invalid environment variables');
}
const raw = parsed.data;
const resolved = {
DATABASE_URL: raw.DATABASE_URL,
SUB2API_BASE_URL: raw.SUB2API_BASE_URL,
SUB2API_ADMIN_API_KEY: raw.SUB2API_ADMIN_API_KEY,
EASY_PAY_PID: pickRequired(raw, 'EASY_PAY_PID', 'ZPAY_PID'),
EASY_PAY_PKEY: pickRequired(raw, 'EASY_PAY_PKEY', 'ZPAY_PKEY'),
EASY_PAY_API_BASE: pickRequired(raw, 'EASY_PAY_API_BASE', 'ZPAY_API_BASE'),
EASY_PAY_NOTIFY_URL: pickRequired(raw, 'EASY_PAY_NOTIFY_URL', 'ZPAY_NOTIFY_URL'),
EASY_PAY_RETURN_URL: pickRequired(raw, 'EASY_PAY_RETURN_URL', 'ZPAY_RETURN_URL'),
EASY_PAY_CID: pickOptional(raw, 'EASY_PAY_CID', 'ZPAY_CID'),
EASY_PAY_CID_ALIPAY: pickOptional(raw, 'EASY_PAY_CID_ALIPAY', 'ZPAY_CID_ALIPAY'),
EASY_PAY_CID_WXPAY: pickOptional(raw, 'EASY_PAY_CID_WXPAY', 'ZPAY_CID_WXPAY'),
ENABLED_PAYMENT_TYPES: raw.ENABLED_PAYMENT_TYPES,
ORDER_TIMEOUT_MINUTES: raw.ORDER_TIMEOUT_MINUTES,
MIN_RECHARGE_AMOUNT: raw.MIN_RECHARGE_AMOUNT,
MAX_RECHARGE_AMOUNT: raw.MAX_RECHARGE_AMOUNT,
PRODUCT_NAME: raw.PRODUCT_NAME,
ADMIN_TOKEN: raw.ADMIN_TOKEN,
NEXT_PUBLIC_APP_URL: raw.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: raw.NEXT_PUBLIC_PAY_HELP_IMAGE_URL,
NEXT_PUBLIC_PAY_HELP_TEXT: raw.NEXT_PUBLIC_PAY_HELP_TEXT,
};
const resolvedParsed = resolvedEnvSchema.safeParse(resolved);
if (!resolvedParsed.success) {
console.error('Invalid resolved env variables:', resolvedParsed.error.flatten().fieldErrors);
throw new Error('Invalid resolved env variables');
}
cachedEnv = resolvedParsed.data;
cachedEnv = parsed.data;
return cachedEnv;
}

View File

@@ -78,7 +78,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
await prisma.order.update({
where: { id: order.id },
data: {
zpayTradeNo: easyPayResult.trade_no,
paymentTradeNo: easyPayResult.trade_no,
payUrl: easyPayResult.payurl || null,
qrCode: easyPayResult.qrcode || null,
},
@@ -205,7 +205,7 @@ export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<
data: {
status: 'PAID',
amount: paidAmount,
zpayTradeNo: params.trade_no,
paymentTradeNo: params.trade_no,
paidAt: new Date(),
failedAt: null,
failedReason: null,
@@ -441,8 +441,8 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
}
try {
if (order.zpayTradeNo) {
await easyPayRefund(order.zpayTradeNo, order.id, amount.toFixed(2));
if (order.paymentTradeNo) {
await easyPayRefund(order.paymentTradeNo, order.id, amount.toFixed(2));
}
await subtractBalance(

70
src/lib/pay-utils.ts Normal file
View File

@@ -0,0 +1,70 @@
export interface UserInfo {
id?: number;
username: string;
balance: number;
}
export interface MyOrder {
id: string;
amount: number;
status: string;
paymentType: string;
createdAt: string;
}
export type OrderStatusFilter = 'ALL' | 'PENDING' | 'PAID' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED' | 'FAILED';
export const STATUS_TEXT_MAP: Record<string, string> = {
PENDING: '待支付',
PAID: '已支付',
RECHARGING: '充值中',
COMPLETED: '已完成',
EXPIRED: '已超时',
CANCELLED: '已取消',
FAILED: '失败',
REFUNDING: '退款中',
REFUNDED: '已退款',
REFUND_FAILED: '退款失败',
};
export const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'PENDING', label: '待支付' },
{ key: 'COMPLETED', label: '已完成' },
{ key: 'CANCELLED', label: '已取消' },
{ key: 'EXPIRED', label: '已超时' },
];
export function detectDeviceIsMobile(): boolean {
if (typeof window === 'undefined') return false;
const ua = navigator.userAgent || '';
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Windows Phone|Mobile/i.test(ua);
const smallPhysicalScreen = Math.min(window.screen.width, window.screen.height) <= 768;
const touchCapable = navigator.maxTouchPoints > 1;
return mobileUA || (touchCapable && smallPhysicalScreen);
}
export function formatStatus(status: string): string {
return STATUS_TEXT_MAP[status] || status;
}
export function formatCreatedAt(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
export function getStatusBadgeClass(status: string, isDark: boolean): string {
if (['COMPLETED', 'PAID'].includes(status)) {
return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700';
}
if (status === 'PENDING') {
return isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-100 text-blue-700';
}
if (['CANCELLED', 'EXPIRED', 'FAILED'].includes(status)) {
return isDark ? 'bg-slate-600 text-slate-200' : 'bg-slate-100 text-slate-700';
}
return isDark ? 'bg-slate-700 text-slate-200' : 'bg-slate-100 text-slate-700';
}

View File

@@ -1,19 +0,0 @@
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);
}

View File

@@ -1,56 +0,0 @@
export interface ZPayCreateParams {
pid: 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 ZPayCreateResponse {
code: number;
msg?: string;
trade_no: string;
O_id?: string;
payurl?: string;
qrcode?: string;
img?: string;
}
export interface ZPayNotifyParams {
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 ZPayQueryResponse {
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 ZPayRefundResponse {
code: number;
msg: string;
}