feat: 支持官方支付宝与易支付支付宝同时展示

- PaymentType 改为 string 类型,支持复合 key(如 alipay_direct)
- 官方支付宝注册为 alipay_direct,易支付保持 alipay/wxpay
- 前端按 PAYMENT_TYPE_META 展示标签区分(官方直连/易支付)
- 管理后台显示统一改为 getPaymentTypeLabel 通用映射
- 修复 admin/OrderTable 中 wechat 拼写错误
This commit is contained in:
erio
2026-03-06 15:33:22 +08:00
parent 01d5a0b3c4
commit f53aa9e14c
14 changed files with 85 additions and 36 deletions

View File

@@ -37,21 +37,21 @@ describe('AlipayProvider', () => {
}); });
describe('metadata', () => { describe('metadata', () => {
it('should have name "alipay"', () => { it('should have name "alipay-direct"', () => {
expect(provider.name).toBe('alipay'); expect(provider.name).toBe('alipay-direct');
}); });
it('should have providerKey "alipay"', () => { it('should have providerKey "alipay"', () => {
expect(provider.providerKey).toBe('alipay'); expect(provider.providerKey).toBe('alipay');
}); });
it('should support "alipay" payment type', () => { it('should support "alipay_direct" payment type', () => {
expect(provider.supportedTypes).toEqual(['alipay']); expect(provider.supportedTypes).toEqual(['alipay_direct']);
}); });
it('should have default limits', () => { it('should have default limits', () => {
expect(provider.defaultLimits).toEqual({ expect(provider.defaultLimits).toEqual({
alipay: { singleMax: 1000, dailyMax: 10000 }, alipay_direct: { singleMax: 1000, dailyMax: 10000 },
}); });
}); });
}); });

View File

@@ -6,7 +6,7 @@ import { getEnv } from '@/lib/config';
const createOrderSchema = z.object({ const createOrderSchema = z.object({
user_id: z.number().int().positive(), user_id: z.number().int().positive(),
amount: z.number().positive(), amount: z.number().positive(),
payment_type: z.enum(['alipay', 'wxpay', 'stripe']), payment_type: z.string().min(1),
src_host: z.string().max(253).optional(), src_host: z.string().max(253).optional(),
src_url: z.string().max(2048).optional(), src_url: z.string().max(2048).optional(),
}); });

View File

@@ -15,7 +15,7 @@ interface OrderResult {
amount: number; amount: number;
payAmount?: number; payAmount?: number;
status: string; status: string;
paymentType: 'alipay' | 'wxpay' | 'stripe'; paymentType: string;
payUrl?: string | null; payUrl?: string | null;
qrCode?: string | null; qrCode?: string | null;
clientSecret?: string | null; clientSecret?: string | null;

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { PAYMENT_TYPE_META } from '@/lib/pay-utils'; import { PAYMENT_TYPE_META, getPaymentIconType } from '@/lib/pay-utils';
export interface MethodLimitInfo { export interface MethodLimitInfo {
available: boolean; available: boolean;
@@ -99,14 +99,15 @@ export default function PaymentForm({
}; };
const renderPaymentIcon = (type: string) => { const renderPaymentIcon = (type: string) => {
if (type === 'alipay') { const iconType = getPaymentIconType(type);
if (iconType === 'alipay') {
return ( return (
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white"> <span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
</span> </span>
); );
} }
if (type === 'wxpay') { if (iconType === 'wxpay') {
return ( return (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white"> <span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor"> <svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
@@ -116,7 +117,7 @@ export default function PaymentForm({
</span> </span>
); );
} }
if (type === 'stripe') { if (iconType === 'stripe') {
return ( return (
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-[#635bff] text-white"> <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-[#635bff] text-white">
<svg <svg
@@ -326,7 +327,7 @@ export default function PaymentForm({
disabled={!isValid || loading} disabled={!isValid || loading}
className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${ className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${
isValid && !loading isValid && !loading
? effectivePaymentType === 'stripe' ? effectivePaymentType.startsWith('stripe')
? 'bg-[#635bff] hover:bg-[#5851db] active:bg-[#4b44c7]' ? 'bg-[#635bff] hover:bg-[#5851db] active:bg-[#4b44c7]'
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800' : 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
: dark : dark

View File

@@ -10,7 +10,7 @@ interface PaymentQRCodeProps {
qrCode?: string | null; qrCode?: string | null;
clientSecret?: string | null; clientSecret?: string | null;
stripePublishableKey?: string | null; stripePublishableKey?: string | null;
paymentType?: 'alipay' | 'wxpay' | 'stripe'; paymentType?: string;
amount: number; amount: number;
payAmount?: number; payAmount?: number;
expiresAt: string; expiresAt: string;
@@ -110,7 +110,7 @@ export default function PaymentQRCode({
}, [qrPayload]); }, [qrPayload]);
// Initialize Stripe Payment Element // Initialize Stripe Payment Element
const isStripe = paymentType === 'stripe'; const isStripe = paymentType?.startsWith('stripe');
useEffect(() => { useEffect(() => {
if (!isStripe || !clientSecret || !stripePublishableKey) return; if (!isStripe || !clientSecret || !stripePublishableKey) return;
@@ -313,7 +313,7 @@ export default function PaymentQRCode({
} }
}; };
const isWx = paymentType === 'wxpay'; const isWx = paymentType?.startsWith('wxpay');
const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg'; const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D'; const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
const iconBgClass = isStripe ? 'bg-[#635bff]' : isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]'; const iconBgClass = isStripe ? 'bg-[#635bff]' : isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]';

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import { getPaymentTypeLabel } from '@/lib/pay-utils';
interface AuditLog { interface AuditLog {
id: string; id: string;
action: string; action: string;
@@ -53,7 +55,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
{ label: 'Payment OK', value: order.paymentSuccess ? 'yes' : 'no' }, { label: 'Payment OK', value: order.paymentSuccess ? 'yes' : 'no' },
{ label: 'Recharge OK', value: order.rechargeSuccess ? 'yes' : 'no' }, { label: 'Recharge OK', value: order.rechargeSuccess ? 'yes' : 'no' },
{ label: 'Recharge Status', value: order.rechargeStatus || '-' }, { label: 'Recharge Status', value: order.rechargeStatus || '-' },
{ label: '支付方式', value: order.paymentType === 'alipay' ? '支付宝' : '微信支付' }, { label: '支付方式', value: getPaymentTypeLabel(order.paymentType) },
{ label: '充值码', value: order.rechargeCode }, { label: '充值码', value: order.rechargeCode },
{ label: '支付单号', value: order.paymentTradeNo || '-' }, { label: '支付单号', value: order.paymentTradeNo || '-' },
{ label: '客户端IP', value: order.clientIp || '-' }, { label: '客户端IP', value: order.clientIp || '-' },

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import { getPaymentTypeLabel } from '@/lib/pay-utils';
interface Order { interface Order {
id: string; id: string;
userId: number; userId: number;
@@ -92,15 +94,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
{statusInfo.label} {statusInfo.label}
</span> </span>
</td> </td>
<td className={tdMuted}> <td className={tdMuted}>{getPaymentTypeLabel(order.paymentType)}</td>
{order.paymentType === 'alipay'
? '支付宝'
: order.paymentType === 'wechat'
? '微信支付'
: order.paymentType === 'stripe'
? 'Stripe'
: order.paymentType}
</td>
<td className={tdMuted}>{order.srcHost || '-'}</td> <td className={tdMuted}>{order.srcHost || '-'}</td>
<td className={tdMuted}>{new Date(order.createdAt).toLocaleString('zh-CN')}</td> <td className={tdMuted}>{new Date(order.createdAt).toLocaleString('zh-CN')}</td>
<td className="whitespace-nowrap px-4 py-3 text-sm"> <td className="whitespace-nowrap px-4 py-3 text-sm">

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import { getPaymentTypeLabel } from '@/lib/pay-utils';
interface PaymentMethod { interface PaymentMethod {
paymentType: string; paymentType: string;
amount: number; amount: number;
@@ -13,8 +15,10 @@ interface PaymentMethodChartProps {
} }
const TYPE_CONFIG: Record<string, { label: string; light: string; dark: string }> = { const TYPE_CONFIG: Record<string, { label: string; light: string; dark: string }> = {
alipay: { label: '支付宝', light: 'bg-blue-500', dark: 'bg-blue-400' }, alipay: { label: '支付宝(易支付)', light: 'bg-cyan-500', dark: 'bg-cyan-400' },
wechat: { label: '微信支付', light: 'bg-green-500', dark: 'bg-green-400' }, alipay_direct: { label: '支付宝(官方)', light: 'bg-blue-500', dark: 'bg-blue-400' },
wxpay: { label: '微信支付(易支付)', light: 'bg-green-500', dark: 'bg-green-400' },
wxpay_direct: { label: '微信支付(官方)', light: 'bg-emerald-500', dark: 'bg-emerald-400' },
stripe: { label: 'Stripe', light: 'bg-purple-500', dark: 'bg-purple-400' }, stripe: { label: 'Stripe', light: 'bg-purple-500', dark: 'bg-purple-400' },
}; };
@@ -48,7 +52,7 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
<div className="space-y-4"> <div className="space-y-4">
{data.map((method) => { {data.map((method) => {
const config = TYPE_CONFIG[method.paymentType] || { const config = TYPE_CONFIG[method.paymentType] || {
label: method.paymentType, label: getPaymentTypeLabel(method.paymentType),
light: 'bg-gray-500', light: 'bg-gray-500',
dark: 'bg-gray-400', dark: 'bg-gray-400',
}; };

View File

@@ -14,11 +14,11 @@ import { getEnv } from '@/lib/config';
import type { AlipayTradeQueryResponse, AlipayTradeRefundResponse, AlipayTradeCloseResponse } from './types'; import type { AlipayTradeQueryResponse, AlipayTradeRefundResponse, AlipayTradeCloseResponse } from './types';
export class AlipayProvider implements PaymentProvider { export class AlipayProvider implements PaymentProvider {
readonly name = 'alipay'; readonly name = 'alipay-direct';
readonly providerKey = 'alipay'; readonly providerKey = 'alipay';
readonly supportedTypes: PaymentType[] = ['alipay']; readonly supportedTypes: PaymentType[] = ['alipay_direct'];
readonly defaultLimits = { readonly defaultLimits = {
alipay: { singleMax: 1000, dailyMax: 10000 }, alipay_direct: { singleMax: 1000, dailyMax: 10000 },
}; };
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> { async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {

View File

@@ -67,6 +67,11 @@ const envSchema = z.object({
.optional() .optional()
.transform((v) => (v !== undefined ? Number(v) : undefined)) .transform((v) => (v !== undefined ? Number(v) : undefined))
.pipe(z.number().min(0).optional()), .pipe(z.number().min(0).optional()),
MAX_DAILY_AMOUNT_ALIPAY_DIRECT: z
.string()
.optional()
.transform((v) => (v !== undefined ? Number(v) : undefined))
.pipe(z.number().min(0).optional()),
MAX_DAILY_AMOUNT_WXPAY: z MAX_DAILY_AMOUNT_WXPAY: z
.string() .string()
.optional() .optional()

View File

@@ -5,7 +5,7 @@ import type { EasyPayCreateResponse, EasyPayQueryResponse, EasyPayRefundResponse
export interface CreatePaymentOptions { export interface CreatePaymentOptions {
outTradeNo: string; outTradeNo: string;
amount: string; amount: string;
paymentType: 'alipay' | 'wxpay'; paymentType: string;
clientIp: string; clientIp: string;
productName: string; productName: string;
} }
@@ -20,7 +20,7 @@ function normalizeCidList(cid?: string): string | undefined {
return normalized || undefined; return normalized || undefined;
} }
function resolveCid(paymentType: 'alipay' | 'wxpay'): string | undefined { function resolveCid(paymentType: string): string | undefined {
const env = getEnv(); const env = getEnv();
if (paymentType === 'alipay') { if (paymentType === 'alipay') {
return normalizeCidList(env.EASY_PAY_CID_ALIPAY) || normalizeCidList(env.EASY_PAY_CID); return normalizeCidList(env.EASY_PAY_CID_ALIPAY) || normalizeCidList(env.EASY_PAY_CID);

View File

@@ -75,19 +75,36 @@ export interface PaymentTypeMeta {
export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = { export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
alipay: { alipay: {
label: '支付宝', label: '支付宝',
sublabel: 'ALIPAY', sublabel: '易支付',
color: '#00AEEF', color: '#00AEEF',
selectedBorder: 'border-cyan-400', selectedBorder: 'border-cyan-400',
selectedBg: 'bg-cyan-50', selectedBg: 'bg-cyan-50',
iconBg: 'bg-[#00AEEF]', iconBg: 'bg-[#00AEEF]',
}, },
alipay_direct: {
label: '支付宝',
sublabel: '官方直连',
color: '#1677FF',
selectedBorder: 'border-blue-500',
selectedBg: 'bg-blue-50',
iconBg: 'bg-[#1677FF]',
},
wxpay: { wxpay: {
label: '微信支付', label: '微信支付',
sublabel: '易支付',
color: '#2BB741', color: '#2BB741',
selectedBorder: 'border-green-500', selectedBorder: 'border-green-500',
selectedBg: 'bg-green-50', selectedBg: 'bg-green-50',
iconBg: 'bg-[#2BB741]', iconBg: 'bg-[#2BB741]',
}, },
wxpay_direct: {
label: '微信支付',
sublabel: '官方直连',
color: '#07C160',
selectedBorder: 'border-green-600',
selectedBg: 'bg-green-50',
iconBg: 'bg-[#07C160]',
},
stripe: { stripe: {
label: 'Stripe', label: 'Stripe',
sublabel: '信用卡 / 借记卡', sublabel: '信用卡 / 借记卡',
@@ -98,6 +115,21 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
}, },
}; };
/** 获取支付方式的显示名称(如 '支付宝(官方直连)' */
export function getPaymentTypeLabel(type: string): string {
const meta = PAYMENT_TYPE_META[type];
if (!meta) return type;
return meta.sublabel ? `${meta.label}${meta.sublabel}` : meta.label;
}
/** 获取基础支付方式图标类型alipay_direct → alipay */
export function getPaymentIconType(type: string): string {
if (type.startsWith('alipay')) return 'alipay';
if (type.startsWith('wxpay')) return 'wxpay';
if (type.startsWith('stripe')) return 'stripe';
return type;
}
export function getStatusBadgeClass(status: string, isDark: boolean): string { export function getStatusBadgeClass(status: string, isDark: boolean): string {
if (['COMPLETED', 'PAID'].includes(status)) { if (['COMPLETED', 'PAID'].includes(status)) {
return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700'; return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700';

View File

@@ -36,7 +36,7 @@ export function initPaymentProviders(): void {
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY) { if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY) {
throw new Error('PAYMENT_PROVIDERS 含 alipay但缺少 ALIPAY_APP_ID 或 ALIPAY_PRIVATE_KEY'); throw new Error('PAYMENT_PROVIDERS 含 alipay但缺少 ALIPAY_APP_ID 或 ALIPAY_PRIVATE_KEY');
} }
paymentRegistry.register(new AlipayProvider()); paymentRegistry.register(new AlipayProvider()); // 注册 alipay_direct
} }
if (providers.includes('stripe')) { if (providers.includes('stripe')) {

View File

@@ -1,5 +1,16 @@
/** Unified payment method types across all providers */ /** Unified payment method types across all providers */
export type PaymentType = 'alipay' | 'wxpay' | 'stripe'; export type PaymentType = string;
/**
* 从复合 key 中提取基础支付方式(如 'alipay_direct' → 'alipay'
* 用于传给第三方 API 时映射回标准名称
*/
export function getBasePaymentType(type: string): string {
if (type.startsWith('alipay')) return 'alipay';
if (type.startsWith('wxpay')) return 'wxpay';
if (type.startsWith('stripe')) return 'stripe';
return type;
}
/** Request to create a payment with any provider */ /** Request to create a payment with any provider */
export interface CreatePaymentRequest { export interface CreatePaymentRequest {