refactor: 常量化订单状态 + 支付渠道/提供商分离显示 + H5自动跳转
- 新增 src/lib/constants.ts,集中管理 ORDER_STATUS / PAYMENT_TYPE / PAYMENT_PREFIX 等常量
- 后端 service/status/timeout/limits 全量替换魔法字符串为 ORDER_STATUS.*
- PaymentTypeMeta 新增 provider 字段,分离 sublabel(选择器展示)与 provider(提供商名称)
- getPaymentDisplayInfo() 返回 { channel, provider } 用于用户端/管理端展示
- 支持通过 PAYMENT_SUBLABEL_* 环境变量覆盖默认 sublabel
- PaymentQRCode: H5 支付自动跳转(含易支付微信 weixin:// scheme 兜底)
- 订单列表/详情页:显示可读的渠道名+提供商,不再暴露内部标识符
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,14 @@ export async function GET(request: NextRequest) {
|
||||
const env = getEnv();
|
||||
const [user, methodLimits] = await Promise.all([getUser(userId), queryMethodLimits(env.ENABLED_PAYMENT_TYPES)]);
|
||||
|
||||
// 收集 sublabel 覆盖(仅包含用户实际配置的项)
|
||||
const sublabelOverrides: Record<string, string> = {};
|
||||
if (env.PAYMENT_SUBLABEL_ALIPAY) sublabelOverrides.alipay = env.PAYMENT_SUBLABEL_ALIPAY;
|
||||
if (env.PAYMENT_SUBLABEL_ALIPAY_DIRECT) sublabelOverrides.alipay_direct = env.PAYMENT_SUBLABEL_ALIPAY_DIRECT;
|
||||
if (env.PAYMENT_SUBLABEL_WXPAY) sublabelOverrides.wxpay = env.PAYMENT_SUBLABEL_WXPAY;
|
||||
if (env.PAYMENT_SUBLABEL_WXPAY_DIRECT) sublabelOverrides.wxpay_direct = env.PAYMENT_SUBLABEL_WXPAY_DIRECT;
|
||||
if (env.PAYMENT_SUBLABEL_STRIPE) sublabelOverrides.stripe = env.PAYMENT_SUBLABEL_STRIPE;
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
@@ -30,6 +38,7 @@ export async function GET(request: NextRequest) {
|
||||
env.ENABLED_PAYMENT_TYPES.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
|
||||
? env.STRIPE_PUBLISHABLE_KEY
|
||||
: null,
|
||||
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,7 +7,7 @@ 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';
|
||||
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
import type { MethodLimitInfo } from '@/components/PaymentForm';
|
||||
|
||||
interface OrderResult {
|
||||
@@ -111,6 +111,10 @@ function PayContent() {
|
||||
helpText: cfgData.config.helpText ?? null,
|
||||
stripePublishableKey: cfgData.config.stripePublishableKey ?? null,
|
||||
});
|
||||
// 应用自定义 sublabel
|
||||
if (cfgData.config.sublabelOverrides) {
|
||||
applySublabelOverrides(cfgData.config.sublabelOverrides);
|
||||
}
|
||||
}
|
||||
} else if (cfgRes.status === 404) {
|
||||
setUserNotFound(true);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
formatStatus,
|
||||
formatCreatedAt,
|
||||
getStatusBadgeClass,
|
||||
getPaymentDisplayInfo,
|
||||
type MyOrder,
|
||||
type OrderStatusFilter,
|
||||
} from '@/lib/pay-utils';
|
||||
@@ -113,7 +114,10 @@ export default function MobileOrderList({
|
||||
</span>
|
||||
</div>
|
||||
<div className={['mt-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{order.paymentType}
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
|
||||
return provider ? `${channel} · ${provider}` : channel;
|
||||
})()}
|
||||
</div>
|
||||
<div className={['mt-0.5 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{formatCreatedAt(order.createdAt)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatStatus, formatCreatedAt, getStatusBadgeClass, type MyOrder } from '@/lib/pay-utils';
|
||||
import { formatStatus, formatCreatedAt, getStatusBadgeClass, getPaymentDisplayInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
|
||||
interface OrderTableProps {
|
||||
isDark: boolean;
|
||||
@@ -67,7 +67,21 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
>
|
||||
<div className="font-medium">#{order.id.slice(0, 12)}</div>
|
||||
<div className="font-semibold">¥{order.amount.toFixed(2)}</div>
|
||||
<div>{order.paymentType}</div>
|
||||
<div>
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
|
||||
return (
|
||||
<>
|
||||
<span>{channel}</span>
|
||||
{provider && (
|
||||
<span className={['ml-1 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{provider}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getPaymentIconSrc,
|
||||
getPaymentChannelLabel,
|
||||
} from '@/lib/pay-utils';
|
||||
import { TERMINAL_STATUSES } from '@/lib/constants';
|
||||
|
||||
interface PaymentQRCodeProps {
|
||||
orderId: string;
|
||||
@@ -36,7 +37,6 @@ const TEXT_BACK = '\u8FD4\u56DE';
|
||||
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
|
||||
const TEXT_H5_HINT =
|
||||
'\u652F\u4ED8\u5B8C\u6210\u540E\u8BF7\u8FD4\u56DE\u6B64\u9875\u9762\uFF0C\u7CFB\u7EDF\u5C06\u81EA\u52A8\u786E\u8BA4';
|
||||
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
|
||||
|
||||
export default function PaymentQRCode({
|
||||
orderId,
|
||||
@@ -62,6 +62,7 @@ export default function PaymentQRCode({
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
const [cancelBlocked, setCancelBlocked] = useState(false);
|
||||
const [redirected, setRedirected] = useState(false);
|
||||
|
||||
// Stripe Payment Element state
|
||||
const [stripeLoaded, setStripeLoaded] = useState(false);
|
||||
@@ -80,6 +81,24 @@ export default function PaymentQRCode({
|
||||
// alipay_direct 使用电脑网站支付,payUrl 是跳转链接不是二维码内容
|
||||
const isRedirect = isRedirectPayment(paymentType);
|
||||
|
||||
// 移动端可用的跳转链接:优先 payUrl,其次尝试 qrCode(微信 weixin:// 协议可直接唤起)
|
||||
const mobileRedirectUrl = payUrl || (qrCode && /^(https?:|weixin:)/i.test(qrCode) ? qrCode : null);
|
||||
|
||||
// 自动跳转:redirect 支付方式 或 移动端 H5
|
||||
const shouldAutoRedirect = !expired && !isStripeType(paymentType) && ((isRedirect && payUrl) || (isMobile && mobileRedirectUrl));
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoRedirect || redirected) return;
|
||||
const url = isRedirect ? payUrl! : mobileRedirectUrl!;
|
||||
setRedirected(true);
|
||||
// embedded iframe 不能 location.href 跳转,用 window.open
|
||||
if (isEmbedded) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
}, [shouldAutoRedirect, redirected, isRedirect, payUrl, mobileRedirectUrl, isEmbedded]);
|
||||
|
||||
const qrPayload = useMemo(() => {
|
||||
if (isRedirect && !qrCode) return '';
|
||||
const value = (qrCode || payUrl || '').trim();
|
||||
@@ -448,31 +467,22 @@ export default function PaymentQRCode({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : isMobile && payUrl ? (
|
||||
) : shouldAutoRedirect ? (
|
||||
<>
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className={`h-8 w-8 animate-spin rounded-full border-2 border-t-transparent`} style={{ borderColor: meta.color, borderTopColor: 'transparent' }} />
|
||||
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
正在跳转到{channelLabel}...
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={payUrl}
|
||||
href={isRedirect ? payUrl! : mobileRedirectUrl!}
|
||||
target={isEmbedded ? '_blank' : '_self'}
|
||||
rel="noopener noreferrer"
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-lg py-3 font-medium text-white shadow-md ${iconBgClass}`}
|
||||
>
|
||||
<img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />
|
||||
{`打开${channelLabel}支付`}
|
||||
</a>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{TEXT_H5_HINT}
|
||||
</p>
|
||||
</>
|
||||
) : isRedirect && payUrl ? (
|
||||
<>
|
||||
<a
|
||||
href={payUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-lg py-3 font-medium text-white shadow-md ${meta.buttonClass}`}
|
||||
>
|
||||
{iconSrc && <img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />}
|
||||
{`前往${channelLabel}收银台`}
|
||||
{redirected ? `未跳转?点击前往${channelLabel}` : `前往${channelLabel}支付`}
|
||||
</a>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{TEXT_H5_HINT}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { getPaymentTypeLabel } from '@/lib/pay-utils';
|
||||
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
@@ -55,7 +55,8 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
{ label: 'Payment OK', value: order.paymentSuccess ? 'yes' : 'no' },
|
||||
{ label: 'Recharge OK', value: order.rechargeSuccess ? 'yes' : 'no' },
|
||||
{ label: 'Recharge Status', value: order.rechargeStatus || '-' },
|
||||
{ label: '支付方式', value: getPaymentTypeLabel(order.paymentType) },
|
||||
{ label: '支付渠道', value: getPaymentDisplayInfo(order.paymentType).channel },
|
||||
{ label: '提供商', value: getPaymentDisplayInfo(order.paymentType).provider || '-' },
|
||||
{ label: '充值码', value: order.rechargeCode },
|
||||
{ label: '支付单号', value: order.paymentTradeNo || '-' },
|
||||
{ label: '客户端IP', value: order.clientIp || '-' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { getPaymentTypeLabel } from '@/lib/pay-utils';
|
||||
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
@@ -94,7 +94,21 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className={tdMuted}>{getPaymentTypeLabel(order.paymentType)}</td>
|
||||
<td className={tdMuted}>
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
|
||||
return (
|
||||
<>
|
||||
{channel}
|
||||
{provider && (
|
||||
<span className={dark ? 'ml-1 text-xs text-slate-500' : 'ml-1 text-xs text-slate-400'}>
|
||||
{provider}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className={tdMuted}>{order.srcHost || '-'}</td>
|
||||
<td className={tdMuted}>{new Date(order.createdAt).toLocaleString('zh-CN')}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
|
||||
@@ -89,6 +89,13 @@ const envSchema = z.object({
|
||||
NEXT_PUBLIC_APP_URL: z.string().url(),
|
||||
PAY_HELP_IMAGE_URL: optionalTrimmedString,
|
||||
PAY_HELP_TEXT: optionalTrimmedString,
|
||||
|
||||
// ── 支付方式前端描述(sublabel)覆盖,不设置则使用默认值 ──
|
||||
PAYMENT_SUBLABEL_ALIPAY: optionalTrimmedString,
|
||||
PAYMENT_SUBLABEL_ALIPAY_DIRECT: optionalTrimmedString,
|
||||
PAYMENT_SUBLABEL_WXPAY: optionalTrimmedString,
|
||||
PAYMENT_SUBLABEL_WXPAY_DIRECT: optionalTrimmedString,
|
||||
PAYMENT_SUBLABEL_STRIPE: optionalTrimmedString,
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
51
src/lib/constants.ts
Normal file
51
src/lib/constants.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** 订单状态 */
|
||||
export const ORDER_STATUS = {
|
||||
PENDING: 'PENDING',
|
||||
PAID: 'PAID',
|
||||
RECHARGING: 'RECHARGING',
|
||||
COMPLETED: 'COMPLETED',
|
||||
EXPIRED: 'EXPIRED',
|
||||
CANCELLED: 'CANCELLED',
|
||||
FAILED: 'FAILED',
|
||||
REFUNDING: 'REFUNDING',
|
||||
REFUNDED: 'REFUNDED',
|
||||
REFUND_FAILED: 'REFUND_FAILED',
|
||||
} as const;
|
||||
|
||||
export type OrderStatus = (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS];
|
||||
|
||||
/** 终态状态集合(不再轮询) */
|
||||
export const TERMINAL_STATUSES = new Set<string>([
|
||||
ORDER_STATUS.COMPLETED,
|
||||
ORDER_STATUS.FAILED,
|
||||
ORDER_STATUS.CANCELLED,
|
||||
ORDER_STATUS.EXPIRED,
|
||||
ORDER_STATUS.REFUNDED,
|
||||
ORDER_STATUS.REFUND_FAILED,
|
||||
]);
|
||||
|
||||
/** 退款相关状态 */
|
||||
export const REFUND_STATUSES = new Set<string>([
|
||||
ORDER_STATUS.REFUNDING,
|
||||
ORDER_STATUS.REFUNDED,
|
||||
ORDER_STATUS.REFUND_FAILED,
|
||||
]);
|
||||
|
||||
/** 支付方式标识 */
|
||||
export const PAYMENT_TYPE = {
|
||||
ALIPAY: 'alipay',
|
||||
ALIPAY_DIRECT: 'alipay_direct',
|
||||
WXPAY: 'wxpay',
|
||||
WXPAY_DIRECT: 'wxpay_direct',
|
||||
STRIPE: 'stripe',
|
||||
} as const;
|
||||
|
||||
/** 支付方式前缀(用于 startsWith 判断) */
|
||||
export const PAYMENT_PREFIX = {
|
||||
ALIPAY: 'alipay',
|
||||
WXPAY: 'wxpay',
|
||||
STRIPE: 'stripe',
|
||||
} as const;
|
||||
|
||||
/** 需要页面跳转(而非二维码)的支付方式 */
|
||||
export const REDIRECT_PAYMENT_TYPES = new Set<string>([PAYMENT_TYPE.ALIPAY_DIRECT]);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { getMethodFeeRate } from './fee';
|
||||
|
||||
@@ -72,7 +73,7 @@ export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<
|
||||
by: ['paymentType'],
|
||||
where: {
|
||||
paymentType: { in: paymentTypes },
|
||||
status: { in: ['PAID', 'RECHARGING', 'COMPLETED'] },
|
||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||
paidAt: { gte: todayStart },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { generateRechargeCode } from './code-gen';
|
||||
import { getMethodDailyLimit } from './limits';
|
||||
import { getMethodFeeRate, calculatePayAmount } from './fee';
|
||||
@@ -44,7 +45,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
}
|
||||
|
||||
const pendingCount = await prisma.order.count({
|
||||
where: { userId: input.userId, status: 'PENDING' },
|
||||
where: { userId: input.userId, status: ORDER_STATUS.PENDING },
|
||||
});
|
||||
if (pendingCount >= MAX_PENDING_ORDERS) {
|
||||
throw new OrderError('TOO_MANY_PENDING', `Too many pending orders (${MAX_PENDING_ORDERS})`, 429);
|
||||
@@ -57,7 +58,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
const dailyAgg = await prisma.order.aggregate({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
status: { in: ['PAID', 'RECHARGING', 'COMPLETED'] },
|
||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||
paidAt: { gte: todayStart },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
@@ -77,7 +78,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
const methodAgg = await prisma.order.aggregate({
|
||||
where: {
|
||||
paymentType: input.paymentType,
|
||||
status: { in: ['PAID', 'RECHARGING', 'COMPLETED'] },
|
||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||
paidAt: { gte: todayStart },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
@@ -158,7 +159,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
amount: input.amount,
|
||||
payAmount,
|
||||
feeRate,
|
||||
status: 'PENDING',
|
||||
status: ORDER_STATUS.PENDING,
|
||||
paymentType: input.paymentType,
|
||||
userName: user.username,
|
||||
userBalance: user.balance,
|
||||
@@ -231,7 +232,7 @@ export async function cancelOrderCore(options: {
|
||||
|
||||
// 2. DB 更新 (WHERE status='PENDING' 保证幂等)
|
||||
const result = await prisma.order.updateMany({
|
||||
where: { id: orderId, status: 'PENDING' },
|
||||
where: { id: orderId, status: ORDER_STATUS.PENDING },
|
||||
data: { status: finalStatus, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
@@ -240,7 +241,7 @@ export async function cancelOrderCore(options: {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
action: finalStatus === 'EXPIRED' ? 'ORDER_EXPIRED' : 'ORDER_CANCELLED',
|
||||
action: finalStatus === ORDER_STATUS.EXPIRED ? 'ORDER_EXPIRED' : 'ORDER_CANCELLED',
|
||||
detail: auditDetail,
|
||||
operator,
|
||||
},
|
||||
@@ -258,13 +259,13 @@ export async function cancelOrder(orderId: string, userId: number): Promise<Canc
|
||||
|
||||
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
if (order.userId !== userId) throw new OrderError('FORBIDDEN', 'Forbidden', 403);
|
||||
if (order.status !== 'PENDING') throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||
if (order.status !== ORDER_STATUS.PENDING) throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||
|
||||
return cancelOrderCore({
|
||||
orderId: order.id,
|
||||
paymentTradeNo: order.paymentTradeNo,
|
||||
paymentType: order.paymentType,
|
||||
finalStatus: 'CANCELLED',
|
||||
finalStatus: ORDER_STATUS.CANCELLED,
|
||||
operator: `user:${userId}`,
|
||||
auditDetail: 'User cancelled order',
|
||||
});
|
||||
@@ -277,13 +278,13 @@ export async function adminCancelOrder(orderId: string): Promise<CancelOutcome>
|
||||
});
|
||||
|
||||
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
if (order.status !== 'PENDING') throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||
if (order.status !== ORDER_STATUS.PENDING) throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||
|
||||
return cancelOrderCore({
|
||||
orderId: order.id,
|
||||
paymentTradeNo: order.paymentTradeNo,
|
||||
paymentType: order.paymentType,
|
||||
finalStatus: 'CANCELLED',
|
||||
finalStatus: ORDER_STATUS.CANCELLED,
|
||||
operator: 'admin',
|
||||
auditDetail: 'Admin cancelled order',
|
||||
});
|
||||
@@ -330,10 +331,10 @@ export async function confirmPayment(input: {
|
||||
const result = await prisma.order.updateMany({
|
||||
where: {
|
||||
id: order.id,
|
||||
status: { in: ['PENDING', 'EXPIRED'] },
|
||||
status: { in: [ORDER_STATUS.PENDING, ORDER_STATUS.EXPIRED] },
|
||||
},
|
||||
data: {
|
||||
status: 'PAID',
|
||||
status: ORDER_STATUS.PAID,
|
||||
amount: paidAmount,
|
||||
paymentTradeNo: input.tradeNo,
|
||||
paidAt: new Date(),
|
||||
@@ -392,13 +393,13 @@ export async function executeRecharge(orderId: string): Promise<void> {
|
||||
if (!order) {
|
||||
throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
}
|
||||
if (order.status === 'COMPLETED') {
|
||||
if (order.status === ORDER_STATUS.COMPLETED) {
|
||||
return;
|
||||
}
|
||||
if (isRefundStatus(order.status)) {
|
||||
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot recharge', 400);
|
||||
}
|
||||
if (order.status !== 'PAID' && order.status !== 'FAILED') {
|
||||
if (order.status !== ORDER_STATUS.PAID && order.status !== ORDER_STATUS.FAILED) {
|
||||
throw new OrderError('INVALID_STATUS', `Order cannot recharge in status ${order.status}`, 400);
|
||||
}
|
||||
|
||||
@@ -412,7 +413,7 @@ export async function executeRecharge(orderId: string): Promise<void> {
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'COMPLETED', completedAt: new Date() },
|
||||
data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() },
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
@@ -427,7 +428,7 @@ export async function executeRecharge(orderId: string): Promise<void> {
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
status: ORDER_STATUS.FAILED,
|
||||
failedAt: new Date(),
|
||||
failedReason: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
@@ -455,15 +456,15 @@ function assertRetryAllowed(order: { status: string; paidAt: Date | null }): voi
|
||||
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400);
|
||||
}
|
||||
|
||||
if (order.status === 'FAILED' || order.status === 'PAID') {
|
||||
if (order.status === ORDER_STATUS.FAILED || order.status === ORDER_STATUS.PAID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (order.status === 'RECHARGING') {
|
||||
if (order.status === ORDER_STATUS.RECHARGING) {
|
||||
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
|
||||
}
|
||||
|
||||
if (order.status === 'COMPLETED') {
|
||||
if (order.status === ORDER_STATUS.COMPLETED) {
|
||||
throw new OrderError('INVALID_STATUS', 'Order already completed', 400);
|
||||
}
|
||||
|
||||
@@ -490,10 +491,10 @@ export async function retryRecharge(orderId: string): Promise<void> {
|
||||
const result = await prisma.order.updateMany({
|
||||
where: {
|
||||
id: orderId,
|
||||
status: { in: ['FAILED', 'PAID'] },
|
||||
status: { in: [ORDER_STATUS.FAILED, ORDER_STATUS.PAID] },
|
||||
paidAt: { not: null },
|
||||
},
|
||||
data: { status: 'PAID', failedAt: null, failedReason: null },
|
||||
data: { status: ORDER_STATUS.PAID, failedAt: null, failedReason: null },
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
@@ -511,7 +512,7 @@ export async function retryRecharge(orderId: string): Promise<void> {
|
||||
}
|
||||
|
||||
const derived = deriveOrderState(latest);
|
||||
if (derived.rechargeStatus === 'recharging' || latest.status === 'PAID') {
|
||||
if (derived.rechargeStatus === 'recharging' || latest.status === ORDER_STATUS.PAID) {
|
||||
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
|
||||
}
|
||||
|
||||
@@ -553,7 +554,7 @@ export interface RefundResult {
|
||||
export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
const order = await prisma.order.findUnique({ where: { id: input.orderId } });
|
||||
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
if (order.status !== 'COMPLETED') {
|
||||
if (order.status !== ORDER_STATUS.COMPLETED) {
|
||||
throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400);
|
||||
}
|
||||
|
||||
@@ -580,8 +581,8 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
}
|
||||
|
||||
const lockResult = await prisma.order.updateMany({
|
||||
where: { id: input.orderId, status: 'COMPLETED' },
|
||||
data: { status: 'REFUNDING' },
|
||||
where: { id: input.orderId, status: ORDER_STATUS.COMPLETED },
|
||||
data: { status: ORDER_STATUS.REFUNDING },
|
||||
});
|
||||
if (lockResult.count === 0) {
|
||||
throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409);
|
||||
@@ -609,7 +610,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
await prisma.order.update({
|
||||
where: { id: input.orderId },
|
||||
data: {
|
||||
status: 'REFUNDED',
|
||||
status: ORDER_STATUS.REFUNDED,
|
||||
refundAmount: new Prisma.Decimal(refundAmount.toFixed(2)),
|
||||
refundReason: input.reason || null,
|
||||
refundAt: new Date(),
|
||||
@@ -631,7 +632,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
await prisma.order.update({
|
||||
where: { id: input.orderId },
|
||||
data: {
|
||||
status: 'REFUND_FAILED',
|
||||
status: ORDER_STATUS.REFUND_FAILED,
|
||||
failedAt: new Date(),
|
||||
failedReason: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ORDER_STATUS, REFUND_STATUSES } from '@/lib/constants';
|
||||
|
||||
export type RechargeStatus = 'not_paid' | 'paid_pending' | 'recharging' | 'success' | 'failed' | 'closed';
|
||||
|
||||
export interface OrderStatusLike {
|
||||
@@ -6,9 +8,13 @@ export interface OrderStatusLike {
|
||||
completedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
const CLOSED_STATUSES = new Set(['EXPIRED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
|
||||
|
||||
const REFUND_STATUSES = new Set(['REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
|
||||
const CLOSED_STATUSES = new Set<string>([
|
||||
ORDER_STATUS.EXPIRED,
|
||||
ORDER_STATUS.CANCELLED,
|
||||
ORDER_STATUS.REFUNDING,
|
||||
ORDER_STATUS.REFUNDED,
|
||||
ORDER_STATUS.REFUND_FAILED,
|
||||
]);
|
||||
|
||||
function hasDate(value: Date | string | null | undefined): boolean {
|
||||
return Boolean(value);
|
||||
@@ -19,7 +25,7 @@ export function isRefundStatus(status: string): boolean {
|
||||
}
|
||||
|
||||
export function isRechargeRetryable(order: OrderStatusLike): boolean {
|
||||
return hasDate(order.paidAt) && order.status === 'FAILED' && !isRefundStatus(order.status);
|
||||
return hasDate(order.paidAt) && order.status === ORDER_STATUS.FAILED && !isRefundStatus(order.status);
|
||||
}
|
||||
|
||||
export function deriveOrderState(order: OrderStatusLike): {
|
||||
@@ -28,17 +34,17 @@ export function deriveOrderState(order: OrderStatusLike): {
|
||||
rechargeStatus: RechargeStatus;
|
||||
} {
|
||||
const paymentSuccess = hasDate(order.paidAt);
|
||||
const rechargeSuccess = hasDate(order.completedAt) || order.status === 'COMPLETED';
|
||||
const rechargeSuccess = hasDate(order.completedAt) || order.status === ORDER_STATUS.COMPLETED;
|
||||
|
||||
if (rechargeSuccess) {
|
||||
return { paymentSuccess, rechargeSuccess: true, rechargeStatus: 'success' };
|
||||
}
|
||||
|
||||
if (order.status === 'RECHARGING') {
|
||||
if (order.status === ORDER_STATUS.RECHARGING) {
|
||||
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'recharging' };
|
||||
}
|
||||
|
||||
if (order.status === 'FAILED') {
|
||||
if (order.status === ORDER_STATUS.FAILED) {
|
||||
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'failed' };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { cancelOrderCore } from './service';
|
||||
|
||||
const INTERVAL_MS = 30_000; // 30 seconds
|
||||
@@ -7,7 +8,7 @@ let timer: ReturnType<typeof setInterval> | null = null;
|
||||
export async function expireOrders(): Promise<number> {
|
||||
const orders = await prisma.order.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
status: ORDER_STATUS.PENDING,
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
select: {
|
||||
@@ -27,7 +28,7 @@ export async function expireOrders(): Promise<number> {
|
||||
orderId: order.id,
|
||||
paymentTradeNo: order.paymentTradeNo,
|
||||
paymentType: order.paymentType,
|
||||
finalStatus: 'EXPIRED',
|
||||
finalStatus: ORDER_STATUS.EXPIRED,
|
||||
operator: 'timeout',
|
||||
auditDetail: 'Order expired',
|
||||
});
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
ORDER_STATUS,
|
||||
PAYMENT_TYPE,
|
||||
PAYMENT_PREFIX,
|
||||
REDIRECT_PAYMENT_TYPES,
|
||||
} from './constants';
|
||||
|
||||
export interface UserInfo {
|
||||
id?: number;
|
||||
username: string;
|
||||
@@ -15,16 +22,16 @@ export interface MyOrder {
|
||||
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: '退款失败',
|
||||
[ORDER_STATUS.PENDING]: '待支付',
|
||||
[ORDER_STATUS.PAID]: '已支付',
|
||||
[ORDER_STATUS.RECHARGING]: '充值中',
|
||||
[ORDER_STATUS.COMPLETED]: '已完成',
|
||||
[ORDER_STATUS.EXPIRED]: '已超时',
|
||||
[ORDER_STATUS.CANCELLED]: '已取消',
|
||||
[ORDER_STATUS.FAILED]: '失败',
|
||||
[ORDER_STATUS.REFUNDING]: '退款中',
|
||||
[ORDER_STATUS.REFUNDED]: '已退款',
|
||||
[ORDER_STATUS.REFUND_FAILED]: '退款失败',
|
||||
};
|
||||
|
||||
export const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
|
||||
@@ -64,8 +71,12 @@ export function formatCreatedAt(value: string): string {
|
||||
}
|
||||
|
||||
export interface PaymentTypeMeta {
|
||||
/** 支付渠道名(用户看到的:支付宝 / 微信支付 / Stripe) */
|
||||
label: string;
|
||||
/** 选择器中的辅助说明(易支付 / 官方 / 信用卡 / 借记卡) */
|
||||
sublabel?: string;
|
||||
/** 提供商名称(易支付 / 支付宝 / 微信支付 / Stripe) */
|
||||
provider: string;
|
||||
color: string;
|
||||
selectedBorder: string;
|
||||
selectedBg: string;
|
||||
@@ -79,9 +90,10 @@ export interface PaymentTypeMeta {
|
||||
}
|
||||
|
||||
export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
alipay: {
|
||||
[PAYMENT_TYPE.ALIPAY]: {
|
||||
label: '支付宝',
|
||||
sublabel: '易支付',
|
||||
provider: '易支付',
|
||||
color: '#00AEEF',
|
||||
selectedBorder: 'border-cyan-400',
|
||||
selectedBg: 'bg-cyan-50',
|
||||
@@ -90,9 +102,10 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
chartBar: { light: 'bg-cyan-500', dark: 'bg-cyan-400' },
|
||||
buttonClass: 'bg-[#00AEEF] hover:bg-[#009dd6] active:bg-[#008cbe]',
|
||||
},
|
||||
alipay_direct: {
|
||||
[PAYMENT_TYPE.ALIPAY_DIRECT]: {
|
||||
label: '支付宝',
|
||||
sublabel: '官方直连',
|
||||
sublabel: '官方',
|
||||
provider: '支付宝',
|
||||
color: '#1677FF',
|
||||
selectedBorder: 'border-blue-500',
|
||||
selectedBg: 'bg-blue-50',
|
||||
@@ -101,9 +114,10 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
chartBar: { light: 'bg-blue-500', dark: 'bg-blue-400' },
|
||||
buttonClass: 'bg-[#1677FF] hover:bg-[#0958d9] active:bg-[#003eb3]',
|
||||
},
|
||||
wxpay: {
|
||||
[PAYMENT_TYPE.WXPAY]: {
|
||||
label: '微信支付',
|
||||
sublabel: '易支付',
|
||||
provider: '易支付',
|
||||
color: '#2BB741',
|
||||
selectedBorder: 'border-green-500',
|
||||
selectedBg: 'bg-green-50',
|
||||
@@ -112,9 +126,10 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
chartBar: { light: 'bg-green-500', dark: 'bg-green-400' },
|
||||
buttonClass: 'bg-[#2BB741] hover:bg-[#24a038] active:bg-[#1d8a2f]',
|
||||
},
|
||||
wxpay_direct: {
|
||||
[PAYMENT_TYPE.WXPAY_DIRECT]: {
|
||||
label: '微信支付',
|
||||
sublabel: '官方直连',
|
||||
sublabel: '官方',
|
||||
provider: '微信支付',
|
||||
color: '#07C160',
|
||||
selectedBorder: 'border-green-600',
|
||||
selectedBg: 'bg-green-50',
|
||||
@@ -123,9 +138,10 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
chartBar: { light: 'bg-emerald-500', dark: 'bg-emerald-400' },
|
||||
buttonClass: 'bg-[#07C160] hover:bg-[#06ad56] active:bg-[#05994c]',
|
||||
},
|
||||
stripe: {
|
||||
[PAYMENT_TYPE.STRIPE]: {
|
||||
label: 'Stripe',
|
||||
sublabel: '信用卡 / 借记卡',
|
||||
provider: 'Stripe',
|
||||
color: '#635bff',
|
||||
selectedBorder: 'border-[#635bff]',
|
||||
selectedBg: 'bg-[#635bff]/10',
|
||||
@@ -135,25 +151,32 @@ 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;
|
||||
}
|
||||
|
||||
/** 获取支付渠道和提供商的结构化信息 */
|
||||
export function getPaymentDisplayInfo(type: string): { channel: string; provider: string } {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
if (!meta) return { channel: type, provider: '' };
|
||||
return { channel: meta.label, provider: meta.provider };
|
||||
}
|
||||
|
||||
/** 获取基础支付方式图标类型(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';
|
||||
if (type.startsWith(PAYMENT_PREFIX.ALIPAY)) return PAYMENT_PREFIX.ALIPAY;
|
||||
if (type.startsWith(PAYMENT_PREFIX.WXPAY)) return PAYMENT_PREFIX.WXPAY;
|
||||
if (type.startsWith(PAYMENT_PREFIX.STRIPE)) return PAYMENT_PREFIX.STRIPE;
|
||||
return type;
|
||||
}
|
||||
|
||||
/** 获取支付方式的元数据,带合理的 fallback */
|
||||
export function getPaymentMeta(type: string): PaymentTypeMeta {
|
||||
const base = getPaymentIconType(type);
|
||||
return PAYMENT_TYPE_META[type] || PAYMENT_TYPE_META[base] || PAYMENT_TYPE_META.alipay;
|
||||
return PAYMENT_TYPE_META[type] || PAYMENT_TYPE_META[base] || PAYMENT_TYPE_META[PAYMENT_TYPE.ALIPAY];
|
||||
}
|
||||
|
||||
/** 获取支付方式图标路径 */
|
||||
@@ -168,30 +191,40 @@ export function getPaymentChannelLabel(type: string): string {
|
||||
|
||||
/** 支付类型谓词函数 */
|
||||
export function isStripeType(type: string | undefined | null): boolean {
|
||||
return !!type?.startsWith('stripe');
|
||||
return !!type?.startsWith(PAYMENT_PREFIX.STRIPE);
|
||||
}
|
||||
|
||||
export function isWxpayType(type: string | undefined | null): boolean {
|
||||
return !!type?.startsWith('wxpay');
|
||||
return !!type?.startsWith(PAYMENT_PREFIX.WXPAY);
|
||||
}
|
||||
|
||||
export function isAlipayType(type: string | undefined | null): boolean {
|
||||
return !!type?.startsWith('alipay');
|
||||
return !!type?.startsWith(PAYMENT_PREFIX.ALIPAY);
|
||||
}
|
||||
|
||||
/** alipay_direct 使用页面跳转而非二维码 */
|
||||
/** 该支付方式需要页面跳转(而非二维码) */
|
||||
export function isRedirectPayment(type: string | undefined | null): boolean {
|
||||
return type === 'alipay_direct';
|
||||
return !!type && REDIRECT_PAYMENT_TYPES.has(type);
|
||||
}
|
||||
|
||||
/** 用自定义 sublabel 覆盖默认值 */
|
||||
export function applySublabelOverrides(overrides: Record<string, string>): void {
|
||||
for (const [type, sublabel] of Object.entries(overrides)) {
|
||||
if (PAYMENT_TYPE_META[type]) {
|
||||
PAYMENT_TYPE_META[type] = { ...PAYMENT_TYPE_META[type], sublabel };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusBadgeClass(status: string, isDark: boolean): string {
|
||||
if (['COMPLETED', 'PAID'].includes(status)) {
|
||||
if (status === ORDER_STATUS.COMPLETED || status === ORDER_STATUS.PAID) {
|
||||
return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700';
|
||||
}
|
||||
if (status === 'PENDING') {
|
||||
if (status === ORDER_STATUS.PENDING) {
|
||||
return isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-100 text-blue-700';
|
||||
}
|
||||
if (['CANCELLED', 'EXPIRED', 'FAILED'].includes(status)) {
|
||||
const GREY_STATUSES = new Set<string>([ORDER_STATUS.CANCELLED, ORDER_STATUS.EXPIRED, ORDER_STATUS.FAILED]);
|
||||
if (GREY_STATUSES.has(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';
|
||||
|
||||
Reference in New Issue
Block a user