diff --git a/prisma/migrations/20260301000000_rename_zpay_trade_no/migration.sql b/prisma/migrations/20260301000000_rename_zpay_trade_no/migration.sql new file mode 100644 index 0000000..d5c48e5 --- /dev/null +++ b/prisma/migrations/20260301000000_rename_zpay_trade_no/migration.sql @@ -0,0 +1,2 @@ +-- Rename zpay_trade_no to payment_trade_no +ALTER TABLE "orders" RENAME COLUMN "zpay_trade_no" TO "payment_trade_no"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0a55edf..5eb32df 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,7 +16,7 @@ model Order { status OrderStatus @default(PENDING) paymentType String @map("payment_type") - zpayTradeNo String? @map("zpay_trade_no") + paymentTradeNo String? @map("payment_trade_no") payUrl String? @map("pay_url") qrCode String? @map("qr_code") qrCodeImg String? @map("qr_code_img") diff --git a/src/__tests__/lib/zpay/sign.test.ts b/src/__tests__/lib/zpay/sign.test.ts deleted file mode 100644 index b7a984d..0000000 --- a/src/__tests__/lib/zpay/sign.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -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); - }); -}); diff --git a/src/app/api/zpay/notify/route.ts b/src/app/api/zpay/notify/route.ts deleted file mode 100644 index 7dc6529..0000000 --- a/src/app/api/zpay/notify/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest } from 'next/server'; -import { handlePaymentNotify } from '@/lib/order/service'; -import type { EasyPayNotifyParams } from '@/lib/easy-pay/types'; - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - - const params: EasyPayNotifyParams = { - pid: searchParams.get('pid') || '', - name: searchParams.get('name') || '', - money: searchParams.get('money') || '', - out_trade_no: searchParams.get('out_trade_no') || '', - trade_no: searchParams.get('trade_no') || '', - param: searchParams.get('param') || '', - trade_status: searchParams.get('trade_status') || '', - type: searchParams.get('type') || '', - sign: searchParams.get('sign') || '', - sign_type: searchParams.get('sign_type') || '', - }; - - const success = await handlePaymentNotify(params); - - // ZPAY requires plain text response - return new Response(success ? 'success' : 'fail', { - headers: { 'Content-Type': 'text/plain' }, - }); - } catch (error) { - console.error('ZPAY notify error:', error); - return new Response('fail', { - headers: { 'Content-Type': 'text/plain' }, - }); - } -} diff --git a/src/app/pay/orders/page.tsx b/src/app/pay/orders/page.tsx index 95f839e..f1200ce 100644 --- a/src/app/pay/orders/page.tsx +++ b/src/app/pay/orders/page.tsx @@ -2,54 +2,11 @@ import { useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useMemo, useState } from 'react'; - -interface UserInfo { - id?: number; - username: string; - balance: number; -} - -interface MyOrder { - id: string; - amount: number; - status: string; - paymentType: string; - createdAt: string; -} - -type OrderStatusFilter = 'ALL' | 'PENDING' | 'PAID' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED' | 'FAILED'; - -const STATUS_TEXT_MAP: Record = { - PENDING: '待支付', - PAID: '已支付', - RECHARGING: '充值中', - COMPLETED: '已完成', - EXPIRED: '已超时', - CANCELLED: '已取消', - FAILED: '失败', - REFUNDING: '退款中', - REFUNDED: '已退款', - REFUND_FAILED: '退款失败', -}; - -const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [ - { key: 'ALL', label: '全部' }, - { key: 'PENDING', label: '待支付' }, - { key: 'COMPLETED', label: '已完成' }, - { key: 'CANCELLED', label: '已取消' }, - { key: 'EXPIRED', label: '已超时' }, -]; - -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); -} +import PayPageLayout from '@/components/PayPageLayout'; +import OrderFilterBar from '@/components/OrderFilterBar'; +import OrderSummaryCards from '@/components/OrderSummaryCards'; +import OrderTable from '@/components/OrderTable'; +import { detectDeviceIsMobile, type UserInfo, type MyOrder, type OrderStatusFilter } from '@/lib/pay-utils'; function OrdersContent() { const searchParams = useSearchParams(); @@ -121,7 +78,7 @@ function OrdersContent() { }); } setOrders([]); - setError('当前链接未携带登录 token,无法查询“我的订单”。'); + setError('当前链接未携带登录 token,无法查询"我的订单"。'); return; } @@ -184,27 +141,6 @@ function OrdersContent() { return { total, pending, completed, failed }; }, [orders]); - const formatStatus = (status: string) => STATUS_TEXT_MAP[status] || status; - - const formatCreatedAt = (value: string) => { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(); - }; - - const getStatusBadgeClass = (status: 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'; - }; - const buildScopedUrl = (path: string) => { const params = new URLSearchParams(); if (effectiveUserId) params.set('user_id', String(effectiveUserId)); @@ -236,154 +172,43 @@ function OrdersContent() { } return ( -
+ + + 返回充值 + + + } > -
-
+ -
-
-
-
- Sub2API Secure Pay -
-

- 我的订单 -

-

- {userInfo?.username || `用户 #${effectiveUserId}`} -

-
-
- - - 返回充值 - -
-
- -
-
-
总订单
-
{summary.total}
-
-
-
待支付
-
{summary.pending}
-
-
-
已完成
-
{summary.completed}
-
-
-
异常/关闭
-
{summary.failed}
-
-
- -
- {FILTER_OPTIONS.map((item) => ( - - ))} -
- -
- {loading ? ( -
-
-
- ) : error ? ( -
- {error} -
- ) : filteredOrders.length === 0 ? ( -
- 暂无符合条件的订单记录 -
- ) : ( - <> -
- 订单号 - 金额 - 支付方式 - 状态 - 创建时间 -
- -
- {filteredOrders.map((order) => ( -
-
#{order.id.slice(0, 12)}
-
¥{order.amount.toFixed(2)}
-
{order.paymentType}
-
- - {formatStatus(order.status)} - -
-
{formatCreatedAt(order.createdAt)}
-
- ))} -
- - )} -
+
+
-
+ + + ); } diff --git a/src/app/pay/page.tsx b/src/app/pay/page.tsx index 28df0cc..853c350 100644 --- a/src/app/pay/page.tsx +++ b/src/app/pay/page.tsx @@ -5,6 +5,9 @@ import { useState, useEffect, Suspense, useMemo } from 'react'; import PaymentForm from '@/components/PaymentForm'; import PaymentQRCode from '@/components/PaymentQRCode'; import OrderStatus from '@/components/OrderStatus'; +import PayPageLayout from '@/components/PayPageLayout'; +import MobileOrderList from '@/components/MobileOrderList'; +import { detectDeviceIsMobile, type UserInfo, type MyOrder } from '@/lib/pay-utils'; interface OrderResult { orderId: string; @@ -16,60 +19,12 @@ interface OrderResult { expiresAt: string; } -interface UserInfo { - id?: number; - username: string; - balance: number; -} - -interface MyOrder { - id: string; - amount: number; - status: string; - paymentType: string; - createdAt: string; -} - interface AppConfig { enabledPaymentTypes: string[]; minAmount: number; maxAmount: number; } -type OrderStatusFilter = 'ALL' | 'PENDING' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED'; - -const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [ - { key: 'ALL', label: '全部' }, - { key: 'PENDING', label: '待支付' }, - { key: 'COMPLETED', label: '已完成' }, - { key: 'CANCELLED', label: '已取消' }, - { key: 'EXPIRED', label: '已超时' }, -]; - -const STATUS_TEXT_MAP: Record = { - PENDING: '待支付', - PAID: '已支付', - RECHARGING: '充值中', - COMPLETED: '已完成', - EXPIRED: '已超时', - CANCELLED: '已取消', - FAILED: '失败', - REFUNDING: '退款中', - REFUNDED: '已退款', - REFUND_FAILED: '退款失败', -}; - -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); -} - function PayContent() { const searchParams = useSearchParams(); const userId = Number(searchParams.get('user_id')); @@ -90,7 +45,6 @@ function PayContent() { const [resolvedUserId, setResolvedUserId] = useState(null); const [myOrders, setMyOrders] = useState([]); const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay'); - const [activeFilter, setActiveFilter] = useState('ALL'); const [config] = useState({ enabledPaymentTypes: ['alipay', 'wxpay'], @@ -177,32 +131,6 @@ function PayContent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [userId, token]); - const filteredOrders = useMemo(() => { - if (activeFilter === 'ALL') return myOrders; - return myOrders.filter((item) => item.status === activeFilter); - }, [myOrders, activeFilter]); - - const formatStatus = (status: string) => STATUS_TEXT_MAP[status] || status; - - const formatCreatedAt = (value: string) => { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(); - }; - - const getStatusBadgeClass = (status: 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'; - }; - if (!effectiveUserId || Number.isNaN(effectiveUserId) || effectiveUserId <= 0) { return (
@@ -306,212 +234,107 @@ function PayContent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [step, finalStatus]); - const renderMobileOrders = () => ( -
-
-

我的订单

- -
- -
- {FILTER_OPTIONS.map((item) => ( + return ( + - ))} -
- - {!hasToken ? ( -
- 当前链接未携带登录 token,无法查询“我的订单”。 -
- ) : filteredOrders.length === 0 ? ( -
- 暂无符合条件的订单记录 -
- ) : ( -
- {filteredOrders.map((order) => ( -
-
- ¥{order.amount.toFixed(2)} - - {formatStatus(order.status)} - -
-
- {order.paymentType} -
-
- {formatCreatedAt(order.createdAt)} -
-
- ))} + + 我的订单 + + + ) : undefined} + > + {error && ( +
+ {error}
)} -
- ); - return ( -
-
-
- -
-
-
-
- Sub2API Secure Pay -
-

- {'Sub2API '}{'\u4F59\u989D\u5145\u503C'} -

-

- {'\u5B89\u5168\u652F\u4ED8\uFF0C\u81EA\u52A8\u5230\u8D26'} -

-
- {!isMobile && ( -
- - - 我的订单 - -
- )} -
- - {error && ( -
- {error} -
- )} - - {step === 'form' && isMobile && ( -
+ - -
- )} + 充值 + + +
+ )} - {step === 'form' && ( - <> - {isMobile ? ( - activeMobileTab === 'pay' ? ( + {step === 'form' && ( + <> + {isMobile ? ( + activeMobileTab === 'pay' ? ( + + ) : ( + + ) + ) : ( +
+
- ) : ( - renderMobileOrders() - ) - ) : ( -
-
- -
-
-
-
支付说明
-
    -
  • 订单完成后会自动到账
  • -
  • 如需历史记录请查看“我的订单”
  • - {!hasToken &&
  • 当前链接无 token,订单查询受限
  • } -
-
- - {hasHelpContent && ( -
-
Support
- {helpImageUrl && ( - help - )} - {helpText && ( -

- {helpText} -

- )} -
- )} -
- )} - - )} +
+
+
支付说明
+
    +
  • 订单完成后会自动到账
  • +
  • 如需历史记录请查看"我的订单"
  • + {!hasToken &&
  • 当前链接无 token,订单查询受限
  • } +
+
- {step === 'paying' && orderResult && ( - - )} + {hasHelpContent && ( +
+
Support
+ {helpImageUrl && ( + help + )} + {helpText && ( +

+ {helpText} +

+ )} +
+ )} +
+
+ )} + + )} - {step === 'result' && ( - - )} -
-
+ {step === 'paying' && orderResult && ( + + )} + + {step === 'result' && ( + + )} + ); } diff --git a/src/components/MobileOrderList.tsx b/src/components/MobileOrderList.tsx new file mode 100644 index 0000000..dd49d09 --- /dev/null +++ b/src/components/MobileOrderList.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import OrderFilterBar from '@/components/OrderFilterBar'; +import { formatStatus, formatCreatedAt, getStatusBadgeClass, type MyOrder, type OrderStatusFilter } from '@/lib/pay-utils'; + +interface MobileOrderListProps { + isDark: boolean; + hasToken: boolean; + orders: MyOrder[]; + onRefresh: () => void; +} + +export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }: MobileOrderListProps) { + const [activeFilter, setActiveFilter] = useState('ALL'); + + const filteredOrders = useMemo(() => { + if (activeFilter === 'ALL') return orders; + return orders.filter((item) => item.status === activeFilter); + }, [orders, activeFilter]); + + return ( +
+
+

我的订单

+ +
+ + + + {!hasToken ? ( +
+ 当前链接未携带登录 token,无法查询"我的订单"。 +
+ ) : filteredOrders.length === 0 ? ( +
+ 暂无符合条件的订单记录 +
+ ) : ( +
+ {filteredOrders.map((order) => ( +
+
+ ¥{order.amount.toFixed(2)} + + {formatStatus(order.status)} + +
+
+ {order.paymentType} +
+
+ {formatCreatedAt(order.createdAt)} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/OrderFilterBar.tsx b/src/components/OrderFilterBar.tsx new file mode 100644 index 0000000..08ea6ef --- /dev/null +++ b/src/components/OrderFilterBar.tsx @@ -0,0 +1,29 @@ +import { FILTER_OPTIONS, type OrderStatusFilter } from '@/lib/pay-utils'; + +interface OrderFilterBarProps { + isDark: boolean; + activeFilter: OrderStatusFilter; + onChange: (filter: OrderStatusFilter) => void; +} + +export default function OrderFilterBar({ isDark, activeFilter, onChange }: OrderFilterBarProps) { + return ( +
+ {FILTER_OPTIONS.map((item) => ( + + ))} +
+ ); +} diff --git a/src/components/OrderSummaryCards.tsx b/src/components/OrderSummaryCards.tsx new file mode 100644 index 0000000..19bfa2c --- /dev/null +++ b/src/components/OrderSummaryCards.tsx @@ -0,0 +1,37 @@ +interface Summary { + total: number; + pending: number; + completed: number; + failed: number; +} + +interface OrderSummaryCardsProps { + isDark: boolean; + summary: Summary; +} + +export default function OrderSummaryCards({ isDark, summary }: OrderSummaryCardsProps) { + const cardClass = ['rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' '); + const labelClass = ['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' '); + + return ( +
+
+
总订单
+
{summary.total}
+
+
+
待支付
+
{summary.pending}
+
+
+
已完成
+
{summary.completed}
+
+
+
异常/关闭
+
{summary.failed}
+
+
+ ); +} diff --git a/src/components/OrderTable.tsx b/src/components/OrderTable.tsx new file mode 100644 index 0000000..6fb7bd7 --- /dev/null +++ b/src/components/OrderTable.tsx @@ -0,0 +1,56 @@ +import { formatStatus, formatCreatedAt, getStatusBadgeClass, type MyOrder } from '@/lib/pay-utils'; + +interface OrderTableProps { + isDark: boolean; + loading: boolean; + error: string; + orders: MyOrder[]; +} + +export default function OrderTable({ isDark, loading, error, orders }: OrderTableProps) { + return ( +
+ {loading ? ( +
+
+
+ ) : error ? ( +
+ {error} +
+ ) : orders.length === 0 ? ( +
+ 暂无符合条件的订单记录 +
+ ) : ( + <> +
+ 订单号 + 金额 + 支付方式 + 状态 + 创建时间 +
+
+ {orders.map((order) => ( +
+
#{order.id.slice(0, 12)}
+
¥{order.amount.toFixed(2)}
+
{order.paymentType}
+
+ + {formatStatus(order.status)} + +
+
{formatCreatedAt(order.createdAt)}
+
+ ))} +
+ + )} +
+ ); +} diff --git a/src/components/PayPageLayout.tsx b/src/components/PayPageLayout.tsx new file mode 100644 index 0000000..7f60a43 --- /dev/null +++ b/src/components/PayPageLayout.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +interface PayPageLayoutProps { + isDark: boolean; + isEmbedded?: boolean; + maxWidth?: 'sm' | 'full'; + title: string; + subtitle: string; + actions?: React.ReactNode; + children: React.ReactNode; +} + +export default function PayPageLayout({ + isDark, + isEmbedded = false, + maxWidth = 'full', + title, + subtitle, + actions, + children, +}: PayPageLayoutProps) { + return ( +
+
+
+ +
+
+
+
+ Sub2API Secure Pay +
+

+ {title} +

+

+ {subtitle} +

+
+ {actions && ( +
+ {actions} +
+ )} +
+ + {children} +
+
+ ); +} diff --git a/src/components/admin/OrderDetail.tsx b/src/components/admin/OrderDetail.tsx index 738a11e..86d64d5 100644 --- a/src/components/admin/OrderDetail.tsx +++ b/src/components/admin/OrderDetail.tsx @@ -18,7 +18,7 @@ interface OrderDetailProps { status: string; paymentType: string; rechargeCode: string; - zpayTradeNo: string | null; + paymentTradeNo: string | null; refundAmount: number | null; refundReason: string | null; refundAt: string | null; @@ -52,7 +52,7 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) { { label: 'Recharge Status', value: order.rechargeStatus || '-' }, { label: '支付方式', value: order.paymentType === 'alipay' ? '支付宝' : '微信支付' }, { label: '充值码', value: order.rechargeCode }, - { label: 'ZPAY订单号', value: order.zpayTradeNo || '-' }, + { label: '支付单号', value: order.paymentTradeNo || '-' }, { label: '客户端IP', value: order.clientIp || '-' }, { label: '创建时间', value: new Date(order.createdAt).toLocaleString('zh-CN') }, { label: '过期时间', value: new Date(order.expiresAt).toLocaleString('zh-CN') }, diff --git a/src/lib/config.ts b/src/lib/config.ts index 8fd6f9b..8541a99 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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; - -type RawEnv = z.infer; - -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; 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; } diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index baefb5d..7c113eb 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -78,7 +78,7 @@ export async function createOrder(input: CreateOrderInput): Promise { } 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( diff --git a/src/lib/pay-utils.ts b/src/lib/pay-utils.ts new file mode 100644 index 0000000..da223f4 --- /dev/null +++ b/src/lib/pay-utils.ts @@ -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 = { + 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'; +} diff --git a/src/lib/zpay/sign.ts b/src/lib/zpay/sign.ts deleted file mode 100644 index 55b3834..0000000 --- a/src/lib/zpay/sign.ts +++ /dev/null @@ -1,19 +0,0 @@ -import crypto from 'crypto'; - -export function generateSign(params: Record, 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, 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); -} diff --git a/src/lib/zpay/types.ts b/src/lib/zpay/types.ts deleted file mode 100644 index 79a3a35..0000000 --- a/src/lib/zpay/types.ts +++ /dev/null @@ -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; -} diff --git a/zpay.md b/zpay.md deleted file mode 100644 index ae740eb..0000000 --- a/zpay.md +++ /dev/null @@ -1,148 +0,0 @@ -如果您的网站已经集成了易支付接口,那么您可以直接使用该API信息,无需另外开发。 -API信息(兼容 易支付 接口) -接口地址:https://zpayz.cn/ - -商户ID(PID):2026022720004756 - -商户密钥(PKEY):YifxyCWYTLW3hXD4Ae7xB9KqtVA2474k - -页面跳转支付 -请求URL -https://zpayz.cn/submit.php -请求方法 -POST 或 GET(推荐POST,不容易被劫持或屏蔽) -此接口可用于用户前台直接发起支付,使用form表单跳转或拼接成url跳转。 -请求参数 -参数 名称 类型 是否必填 描述 范例 -name 商品名称 String 是 需体现出具体售卖的商品,否则容易被封 iPhone17苹果手机 -money 订单金额 String 是 最多保留两位小数 5.67 -type 支付方式 String 是 支付宝:alipay 微信支付:wxpay alipay -out_trade_no 商户订单号 Num 是 每个商品不可重复,最多32位 201911914837526544601 -notify_url 异步通知页面 String 是 交易信息回调页面,不支持带参数 http://www.aaa.com/bbb.php -pid 商户唯一标识 String 是 一串字母数字组合 201901151314084206659771 -cid 支付渠道ID String 否 支持填写多个,使用,隔开,如果不填则随机调用 1234 -param 附加内容 String 否 会通过notify_url原样返回 金色 256G -return_url 跳转页面 String 是 交易完成后浏览器跳转,不支持带参数 http://www.aaa.com/ccc.php -sign 签名(参考本页签名算法) String 是 用于验证信息正确性,采用md5加密 28f9583617d9caf66834292b6ab1cc89 -sign_type 签名方法 String 是 默认为MD5 MD5 -用法举例 -https://zpayz.cn/submit.php?name=iphone xs Max 一台&money=0.03&out_trade_no=201911914837526544601¬ify_url=http://www.aaa.com/notify_url.php&pid=201901151314084206659771¶m=金色 256G&return_url=http://www.baidu.com&sign=28f9583617d9caf66834292b6ab1cc89&sign_type=MD5&type=alipay - -成功返回 -直接跳转到付款页面 -说明:该页面为收银台,直接访问这个url即可进行付款 -失败返回 -{"code":"error","msg":"具体的错误信息"} -API接口支付 -请求URL -https://zpayz.cn/mapi.php -请求方法 -POST(方式为form-data) -请求参数 -字段名 变量名 必填 类型 示例值 描述 -商户ID pid 是 String 1001 -支付渠道ID cid 否 String 1234 支持填写多个,使用,隔开,如果不填则随机调用 -支付方式 type 是 String alipay 支付宝:alipay 微信支付:wxpay -商户订单号 out_trade_no 是 String 20160806151343349 每个商品不可重复,最多32位 -异步通知地址 notify_url 是 String http://www.pay.com/notify_url.php 服务器异步通知地址 -商品名称 name 是 String iPhone17苹果手机 需体现出具体售卖的商品,否则容易被封 -商品金额 money 是 String 1.00 单位:元,最大2位小数 -用户IP地址 clientip 是 String 192.168.1.100 用户发起支付的IP地址 -设备类型 device 否 String pc 根据当前用户浏览器的UA判断, -传入用户所使用的浏览器 -或设备类型,默认为pc -业务扩展参数 param 否 String 没有请留空 支付后原样返回 -签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法参考本页底部 -签名类型 sign_type 是 String MD5 默认为MD5 -成功返回 -字段名 变量名 类型 示例值 描述 -返回状态码 code Int 1 1为成功,其它值为失败 -返回信息 msg String 失败时返回原因 -订单号 trade_no String 20160806151343349 支付订单号 -ZPAY内部订单号 O_id String 123456 ZPAY内部订单号 -支付跳转url payurl String https://xxx.cn/pay/wxpay/202010903/ 如果返回该字段,则直接跳转到该url支付 -二维码链接 qrcode String https://xxx.cn/pay/wxpay/202010903/ 如果返回该字段,则根据该url生成二维码 -二维码图片 img String https://zpayz.cn/qrcode/123.jpg 该字段为付款二维码的图片地址 -失败返回 -{"code":"error","msg":"具体的错误信息"} -查询单个订单 -请求URL -https://zpayz.cn/api.php?act=order&pid={商户ID}&key={商户密钥}&out_trade_no={商户订单号} -请求方法 -GET -请求参数 -参数 名称 类型 必填 描述 范例 -act 操作类型 String 是 此API固定值 order -pid 商户ID String 是 20220715225121 -key 商户密钥 String 是 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i -trade_no 系统订单号 String 选择 20160806151343312 -out_trade_no 商户订单号 String 选择 20160806151343349 -返回结果 -字段名 变量名 类型 示例值 描述 -返回状态码 code Int 1 1为成功,其它值为失败 -返回信息 msg String 查询订单号成功! -易支付订单号 trade_no String 2016080622555342651 易支付订单号 -商户订单号 out_trade_no String 20160806151343349 商户系统内部的订单号 -支付方式 type String alipay 支付宝:alipay 微信支付:wxpay -商户ID pid String 20220715225121 发起支付的商户ID -创建订单时间 addtime String 2016-08-06 22:55:52 -完成交易时间 endtime String 2016-08-06 22:55:52 -商品名称 name String VIP会员 -商品金额 money String 1.00 -支付状态 status Int 0 1为支付成功,0为未支付 -业务扩展参数 param String 默认留空 -支付者账号 buyer String 默认留空 -提交订单退款 -请求URL -https://zpayz.cn/api.php?act=refund -请求方法 -POST -请求参数 -字段名 变量名 必填 类型 示例值 描述 -商户ID pid 是 String 20220715225121 -商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i -易支付订单号 trade_no 特殊可选 String 20160806151343349021 易支付订单号 -商户订单号 out_trade_no 特殊可选 String 20160806151343349 订单支付时传入的商户订单号,商家自定义且保证商家系统中唯一 -退款金额 money 是 String 1.50 大多数通道需要与原订单金额一致 -返回结果 -字段名 变量名 类型 示例值 描述 -返回状态码 code Int 1 1为成功,其它值为失败 -返回信息 msg String 退款成功 -支付结果通知 -请求URL -服务器异步通知(notify_url)、页面跳转通知(return_url) -请求方法 -GET -请求参数 -参数 名称 类型 描述 范例 -pid 商户ID Int 201901151314084206659771 -name 商品名称 String 商品名称不超过100字 iphone -money 订单金额 String 最多保留两位小数 5.67 -out_trade_no 商户订单号 Num 商户系统内部的订单号 201901191324552185692680 -trade_no 易支付订单号 String 易支付订单号 2019011922001418111011411195 -param 业务扩展参数 String 会通过notify_url原样返回 金色 256G -trade_status 支付状态 String 只有TRADE_SUCCESS是成功 TRADE_SUCCESS -type 支付方式 String 包括支付宝、微信 alipay -sign 签名(参考本页签名算法) String 用于验证接受信息的正确性 ef6e3c5c6ff45018e8c82fd66fb056dc -sign_type 签名类型 String 默认为MD5 MD5 -如何验证 -请根据签名算法,验证自己生成的签名与参数中传入的签名是否一致,如果一致则说明是由官方向您发送的真实信息 -注意事项 -1.收到回调信息后请返回“success”,否则程序将判定您的回调地址未正确通知到。 - -2.同样的通知可能会多次发送给商户系统。商户系统必须能够正确处理重复的通知。 - -3.推荐的做法是,当收到通知进行处理时,首先检查对应业务数据的状态,判断该通知是否已经处理过,如果没有处理过再进行处理,如果处理过直接返回结果成功。在对业务数据进行状态检查和处理之前,要采用数据锁进行并发控制,以避免函数重入造成的数据混乱。 - -4.特别提醒:商户系统对于支付结果通知的内容一定要做签名验证,并校验返回的订单金额是否与商户侧的订单金额一致,防止数据泄漏导致出现“假通知”,造成资金损失。 - -5.对后台通知交互时,如果平台收到商户的应答不是纯字符串success或超过5秒后返回时,平台认为通知失败,平台会通过一定的策略(通知频率为0/15/15/30/180/1800/1800/1800/1800/3600,单位:秒)间接性重新发起通知,尽可能提高通知的成功率,但不保证通知最终能成功。 - -MD5签名算法 -1、将发送或接收到的所有参数按照参数名ASCII码从小到大排序(a-z),sign、sign_type、和空值不参与签名! - -2、将排序后的参数拼接成URL键值对的格式,例如 a=b&c=d&e=f,参数值不要进行url编码。 - -3、再将拼接好的字符串与商户密钥KEY进行MD5加密得出sign签名参数,sign = md5 ( a=b&c=d&e=f + KEY ) (注意:+ 为各语言的拼接符,不是字符!),md5结果为小写。 - -4、具体签名与发起支付的示例代码可下载SDK查看。 \ No newline at end of file