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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
70
src/lib/pay-utils.ts
Normal 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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user