2026-03-01 03:04:24 +08:00
|
|
|
|
import { prisma } from '@/lib/db';
|
|
|
|
|
|
import { getEnv } from '@/lib/config';
|
2026-03-06 17:34:42 +08:00
|
|
|
|
import { ORDER_STATUS } from '@/lib/constants';
|
2026-03-01 03:04:24 +08:00
|
|
|
|
import { generateRechargeCode } from './code-gen';
|
2026-03-01 21:53:09 +08:00
|
|
|
|
import { getMethodDailyLimit } from './limits';
|
2026-03-03 22:00:44 +08:00
|
|
|
|
import { getMethodFeeRate, calculatePayAmount } from './fee';
|
2026-03-01 17:58:08 +08:00
|
|
|
|
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
|
|
|
|
|
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
2026-03-13 21:19:22 +08:00
|
|
|
|
import { getUser, createAndRedeem, subtractBalance, addBalance, getGroup } from '@/lib/sub2api/client';
|
|
|
|
|
|
import { computeValidityDays, type ValidityUnit } from '@/lib/subscription-utils';
|
2026-03-01 03:04:24 +08:00
|
|
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
|
|
import { deriveOrderState, isRefundStatus } from './status';
|
2026-03-09 18:33:57 +08:00
|
|
|
|
import { pickLocaleText, type Locale } from '@/lib/locale';
|
2026-03-10 11:52:37 +08:00
|
|
|
|
import { getBizDayStartUTC } from '@/lib/time/biz-day';
|
|
|
|
|
|
import { buildOrderResultUrl, createOrderStatusAccessToken } from '@/lib/order/status-access';
|
2026-03-01 03:04:24 +08:00
|
|
|
|
|
|
|
|
|
|
const MAX_PENDING_ORDERS = 3;
|
|
|
|
|
|
|
2026-03-09 18:33:57 +08:00
|
|
|
|
function message(locale: Locale, zh: string, en: string): string {
|
|
|
|
|
|
return pickLocaleText(locale, zh, en);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 03:04:24 +08:00
|
|
|
|
export interface CreateOrderInput {
|
|
|
|
|
|
userId: number;
|
|
|
|
|
|
amount: number;
|
2026-03-01 17:58:08 +08:00
|
|
|
|
paymentType: PaymentType;
|
2026-03-01 03:04:24 +08:00
|
|
|
|
clientIp: string;
|
2026-03-06 14:04:51 +08:00
|
|
|
|
isMobile?: boolean;
|
2026-03-02 20:40:16 +08:00
|
|
|
|
srcHost?: string;
|
|
|
|
|
|
srcUrl?: string;
|
2026-03-09 18:33:57 +08:00
|
|
|
|
locale?: Locale;
|
2026-03-13 19:06:25 +08:00
|
|
|
|
// 订阅订单专用
|
|
|
|
|
|
orderType?: 'balance' | 'subscription';
|
|
|
|
|
|
planId?: string;
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface CreateOrderResult {
|
|
|
|
|
|
orderId: string;
|
|
|
|
|
|
amount: number;
|
2026-03-03 22:00:44 +08:00
|
|
|
|
payAmount: number;
|
|
|
|
|
|
feeRate: number;
|
2026-03-01 03:04:24 +08:00
|
|
|
|
status: string;
|
2026-03-01 17:58:08 +08:00
|
|
|
|
paymentType: PaymentType;
|
2026-03-01 03:04:24 +08:00
|
|
|
|
userName: string;
|
|
|
|
|
|
userBalance: number;
|
|
|
|
|
|
payUrl?: string | null;
|
|
|
|
|
|
qrCode?: string | null;
|
2026-03-04 10:58:07 +08:00
|
|
|
|
clientSecret?: string | null;
|
2026-03-01 03:04:24 +08:00
|
|
|
|
expiresAt: Date;
|
2026-03-10 11:52:37 +08:00
|
|
|
|
statusAccessToken: string;
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function createOrder(input: CreateOrderInput): Promise<CreateOrderResult> {
|
|
|
|
|
|
const env = getEnv();
|
2026-03-09 18:33:57 +08:00
|
|
|
|
const locale = input.locale ?? 'zh';
|
2026-03-10 11:52:37 +08:00
|
|
|
|
const todayStart = getBizDayStartUTC();
|
2026-03-13 19:06:25 +08:00
|
|
|
|
const orderType = input.orderType ?? 'balance';
|
|
|
|
|
|
|
|
|
|
|
|
// ── 订阅订单前置校验 ──
|
2026-03-13 21:19:22 +08:00
|
|
|
|
let subscriptionPlan: { id: string; groupId: number; price: Prisma.Decimal; validityDays: number; validityUnit: string; name: string } | null = null;
|
2026-03-13 19:06:25 +08:00
|
|
|
|
if (orderType === 'subscription') {
|
|
|
|
|
|
if (!input.planId) {
|
|
|
|
|
|
throw new OrderError('INVALID_INPUT', message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'), 400);
|
|
|
|
|
|
}
|
|
|
|
|
|
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: input.planId } });
|
|
|
|
|
|
if (!plan || !plan.forSale) {
|
|
|
|
|
|
throw new OrderError('PLAN_NOT_AVAILABLE', message(locale, '该套餐不存在或未上架', 'Plan not found or not for sale'), 404);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 校验 Sub2API 分组仍然存在
|
|
|
|
|
|
const group = await getGroup(plan.groupId);
|
|
|
|
|
|
if (!group || group.status !== 'active') {
|
|
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'GROUP_NOT_FOUND',
|
|
|
|
|
|
message(locale, '订阅分组已下架,无法购买', 'Subscription group is no longer available'),
|
|
|
|
|
|
410,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
subscriptionPlan = plan;
|
|
|
|
|
|
// 订阅订单金额使用服务端套餐价格,不信任客户端
|
|
|
|
|
|
input.amount = Number(plan.price);
|
|
|
|
|
|
}
|
2026-03-01 03:04:24 +08:00
|
|
|
|
|
|
|
|
|
|
const user = await getUser(input.userId);
|
|
|
|
|
|
if (user.status !== 'active') {
|
2026-03-09 18:33:57 +08:00
|
|
|
|
throw new OrderError('USER_INACTIVE', message(locale, '用户账号已被禁用', 'User account is disabled'), 422);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pendingCount = await prisma.order.count({
|
2026-03-06 17:34:42 +08:00
|
|
|
|
where: { userId: input.userId, status: ORDER_STATUS.PENDING },
|
2026-03-01 03:04:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
if (pendingCount >= MAX_PENDING_ORDERS) {
|
2026-03-09 18:33:57 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'TOO_MANY_PENDING',
|
2026-03-10 18:20:36 +08:00
|
|
|
|
message(
|
|
|
|
|
|
locale,
|
|
|
|
|
|
`待支付订单过多(最多 ${MAX_PENDING_ORDERS} 笔)`,
|
|
|
|
|
|
`Too many pending orders (${MAX_PENDING_ORDERS})`,
|
|
|
|
|
|
),
|
2026-03-09 18:33:57 +08:00
|
|
|
|
429,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 19:41:44 +08:00
|
|
|
|
// 每日累计充值限额校验(0 = 不限制)
|
|
|
|
|
|
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
|
|
|
|
|
|
const dailyAgg = await prisma.order.aggregate({
|
|
|
|
|
|
where: {
|
|
|
|
|
|
userId: input.userId,
|
2026-03-06 17:34:42 +08:00
|
|
|
|
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
2026-03-01 19:41:44 +08:00
|
|
|
|
paidAt: { gte: todayStart },
|
|
|
|
|
|
},
|
|
|
|
|
|
_sum: { amount: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
|
|
|
|
|
|
if (alreadyPaid + input.amount > env.MAX_DAILY_RECHARGE_AMOUNT) {
|
|
|
|
|
|
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
|
2026-03-09 18:33:57 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'DAILY_LIMIT_EXCEEDED',
|
|
|
|
|
|
message(
|
|
|
|
|
|
locale,
|
|
|
|
|
|
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`,
|
|
|
|
|
|
`Daily recharge limit reached. Remaining amount: ${remaining.toFixed(2)} CNY`,
|
|
|
|
|
|
),
|
|
|
|
|
|
429,
|
|
|
|
|
|
);
|
2026-03-01 19:41:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 21:53:09 +08:00
|
|
|
|
// 渠道每日全平台限额校验(0 = 不限)
|
|
|
|
|
|
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
|
|
|
|
|
|
if (methodDailyLimit > 0) {
|
|
|
|
|
|
const methodAgg = await prisma.order.aggregate({
|
|
|
|
|
|
where: {
|
|
|
|
|
|
paymentType: input.paymentType,
|
2026-03-06 17:34:42 +08:00
|
|
|
|
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
2026-03-01 21:53:09 +08:00
|
|
|
|
paidAt: { gte: todayStart },
|
|
|
|
|
|
},
|
|
|
|
|
|
_sum: { amount: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
const methodUsed = Number(methodAgg._sum.amount ?? 0);
|
|
|
|
|
|
if (methodUsed + input.amount > methodDailyLimit) {
|
|
|
|
|
|
const remaining = Math.max(0, methodDailyLimit - methodUsed);
|
|
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'METHOD_DAILY_LIMIT_EXCEEDED',
|
|
|
|
|
|
remaining > 0
|
2026-03-09 18:33:57 +08:00
|
|
|
|
? message(
|
|
|
|
|
|
locale,
|
|
|
|
|
|
`${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式`,
|
|
|
|
|
|
`${input.paymentType} remaining daily quota: ${remaining.toFixed(2)} CNY. Reduce the amount or use another payment method`,
|
|
|
|
|
|
)
|
|
|
|
|
|
: message(
|
|
|
|
|
|
locale,
|
|
|
|
|
|
`${input.paymentType} 今日充值额度已满,请使用其他支付方式`,
|
|
|
|
|
|
`${input.paymentType} daily quota is full. Please use another payment method`,
|
|
|
|
|
|
),
|
2026-03-01 21:53:09 +08:00
|
|
|
|
429,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:00:44 +08:00
|
|
|
|
const feeRate = getMethodFeeRate(input.paymentType);
|
2026-03-13 23:03:01 +08:00
|
|
|
|
const payAmountStr = calculatePayAmount(input.amount, feeRate);
|
|
|
|
|
|
const payAmountNum = Number(payAmountStr);
|
2026-03-03 22:00:44 +08:00
|
|
|
|
|
2026-03-01 03:04:24 +08:00
|
|
|
|
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
|
2026-03-07 04:15:48 +08:00
|
|
|
|
const order = await prisma.$transaction(async (tx) => {
|
|
|
|
|
|
const created = await tx.order.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
|
userEmail: user.email,
|
|
|
|
|
|
userName: user.username,
|
|
|
|
|
|
userNotes: user.notes || null,
|
|
|
|
|
|
amount: new Prisma.Decimal(input.amount.toFixed(2)),
|
2026-03-13 23:03:01 +08:00
|
|
|
|
payAmount: new Prisma.Decimal(payAmountStr),
|
|
|
|
|
|
feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(4)) : null,
|
2026-03-07 04:15:48 +08:00
|
|
|
|
rechargeCode: '',
|
|
|
|
|
|
status: 'PENDING',
|
|
|
|
|
|
paymentType: input.paymentType,
|
|
|
|
|
|
expiresAt,
|
|
|
|
|
|
clientIp: input.clientIp,
|
|
|
|
|
|
srcHost: input.srcHost || null,
|
|
|
|
|
|
srcUrl: input.srcUrl || null,
|
2026-03-13 19:06:25 +08:00
|
|
|
|
orderType,
|
|
|
|
|
|
planId: subscriptionPlan?.id ?? null,
|
|
|
|
|
|
subscriptionGroupId: subscriptionPlan?.groupId ?? null,
|
2026-03-13 21:19:22 +08:00
|
|
|
|
subscriptionDays: subscriptionPlan
|
|
|
|
|
|
? computeValidityDays(subscriptionPlan.validityDays, subscriptionPlan.validityUnit as ValidityUnit)
|
|
|
|
|
|
: null,
|
2026-03-07 04:15:48 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-03-01 03:04:24 +08:00
|
|
|
|
|
2026-03-07 04:15:48 +08:00
|
|
|
|
const rechargeCode = generateRechargeCode(created.id);
|
|
|
|
|
|
await tx.order.update({
|
|
|
|
|
|
where: { id: created.id },
|
|
|
|
|
|
data: { rechargeCode },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return { ...created, rechargeCode };
|
2026-03-01 03:04:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-01 17:58:08 +08:00
|
|
|
|
initPaymentProviders();
|
|
|
|
|
|
const provider = paymentRegistry.getProvider(input.paymentType);
|
2026-03-06 13:57:52 +08:00
|
|
|
|
|
2026-03-10 11:52:37 +08:00
|
|
|
|
const statusAccessToken = createOrderStatusAccessToken(order.id);
|
|
|
|
|
|
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id);
|
|
|
|
|
|
|
|
|
|
|
|
// 只有 easypay 从外部传入 notifyUrl,return_url 统一回到带访问令牌的结果页
|
2026-03-06 13:57:52 +08:00
|
|
|
|
let notifyUrl: string | undefined;
|
2026-03-10 11:52:37 +08:00
|
|
|
|
let returnUrl: string | undefined = orderResultUrl;
|
2026-03-06 13:57:52 +08:00
|
|
|
|
if (provider.providerKey === 'easypay') {
|
|
|
|
|
|
notifyUrl = env.EASY_PAY_NOTIFY_URL || '';
|
2026-03-10 11:52:37 +08:00
|
|
|
|
returnUrl = orderResultUrl;
|
2026-03-06 13:57:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:58:08 +08:00
|
|
|
|
const paymentResult = await provider.createPayment({
|
|
|
|
|
|
orderId: order.id,
|
2026-03-13 23:03:01 +08:00
|
|
|
|
amount: payAmountNum,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
paymentType: input.paymentType,
|
2026-03-13 23:03:01 +08:00
|
|
|
|
subject: `${env.PRODUCT_NAME} ${payAmountStr} CNY`,
|
2026-03-06 13:57:52 +08:00
|
|
|
|
notifyUrl,
|
|
|
|
|
|
returnUrl,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
clientIp: input.clientIp,
|
2026-03-06 18:04:11 +08:00
|
|
|
|
isMobile: input.isMobile,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.order.update({
|
|
|
|
|
|
where: { id: order.id },
|
|
|
|
|
|
data: {
|
2026-03-01 17:58:08 +08:00
|
|
|
|
paymentTradeNo: paymentResult.tradeNo,
|
|
|
|
|
|
payUrl: paymentResult.payUrl || null,
|
|
|
|
|
|
qrCode: paymentResult.qrCode || null,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId: order.id,
|
|
|
|
|
|
action: 'ORDER_CREATED',
|
2026-03-13 19:06:25 +08:00
|
|
|
|
detail: JSON.stringify({
|
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
|
amount: input.amount,
|
|
|
|
|
|
paymentType: input.paymentType,
|
|
|
|
|
|
orderType,
|
|
|
|
|
|
...(subscriptionPlan && { planId: subscriptionPlan.id, planName: subscriptionPlan.name, groupId: subscriptionPlan.groupId }),
|
|
|
|
|
|
}),
|
2026-03-01 03:04:24 +08:00
|
|
|
|
operator: `user:${input.userId}`,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
orderId: order.id,
|
|
|
|
|
|
amount: input.amount,
|
2026-03-13 23:03:01 +08:00
|
|
|
|
payAmount: payAmountNum,
|
2026-03-03 22:00:44 +08:00
|
|
|
|
feeRate,
|
2026-03-06 17:34:42 +08:00
|
|
|
|
status: ORDER_STATUS.PENDING,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
paymentType: input.paymentType,
|
|
|
|
|
|
userName: user.username,
|
|
|
|
|
|
userBalance: user.balance,
|
2026-03-01 17:58:08 +08:00
|
|
|
|
payUrl: paymentResult.payUrl,
|
|
|
|
|
|
qrCode: paymentResult.qrCode,
|
2026-03-04 10:58:07 +08:00
|
|
|
|
clientSecret: paymentResult.clientSecret,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
expiresAt,
|
2026-03-10 11:52:37 +08:00
|
|
|
|
statusAccessToken,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
};
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
await prisma.order.delete({ where: { id: order.id } });
|
2026-03-01 19:56:41 +08:00
|
|
|
|
|
|
|
|
|
|
// 已经是业务错误,直接向上抛
|
|
|
|
|
|
if (error instanceof OrderError) throw error;
|
|
|
|
|
|
|
|
|
|
|
|
// 支付网关配置缺失或调用失败,转成友好错误
|
|
|
|
|
|
const msg = error instanceof Error ? error.message : String(error);
|
2026-03-04 10:58:07 +08:00
|
|
|
|
console.error(`Payment gateway error (${input.paymentType}):`, error);
|
2026-03-01 19:56:41 +08:00
|
|
|
|
if (msg.includes('environment variables') || msg.includes('not configured') || msg.includes('not found')) {
|
2026-03-09 18:33:57 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'PAYMENT_GATEWAY_ERROR',
|
2026-03-10 18:20:36 +08:00
|
|
|
|
message(
|
|
|
|
|
|
locale,
|
|
|
|
|
|
`支付渠道(${input.paymentType})暂未配置,请联系管理员`,
|
|
|
|
|
|
`Payment method (${input.paymentType}) is not configured. Please contact the administrator`,
|
|
|
|
|
|
),
|
2026-03-09 18:33:57 +08:00
|
|
|
|
503,
|
|
|
|
|
|
);
|
2026-03-01 19:56:41 +08:00
|
|
|
|
}
|
2026-03-09 18:33:57 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'PAYMENT_GATEWAY_ERROR',
|
2026-03-10 18:20:36 +08:00
|
|
|
|
message(
|
|
|
|
|
|
locale,
|
|
|
|
|
|
'支付渠道暂时不可用,请稍后重试或更换支付方式',
|
|
|
|
|
|
'Payment method is temporarily unavailable. Please try again later or use another payment method',
|
|
|
|
|
|
),
|
2026-03-09 18:33:57 +08:00
|
|
|
|
502,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 18:44:49 +08:00
|
|
|
|
export type CancelOutcome = 'cancelled' | 'already_paid';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 核心取消逻辑 — 所有取消路径共用。
|
|
|
|
|
|
* 调用前由 caller 负责权限校验(userId / admin 身份)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function cancelOrderCore(options: {
|
|
|
|
|
|
orderId: string;
|
|
|
|
|
|
paymentTradeNo: string | null;
|
|
|
|
|
|
paymentType: string | null;
|
|
|
|
|
|
finalStatus: 'CANCELLED' | 'EXPIRED';
|
|
|
|
|
|
operator: string;
|
|
|
|
|
|
auditDetail: string;
|
|
|
|
|
|
}): Promise<CancelOutcome> {
|
|
|
|
|
|
const { orderId, paymentTradeNo, paymentType, finalStatus, operator, auditDetail } = options;
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 平台侧处理
|
|
|
|
|
|
if (paymentTradeNo && paymentType) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
initPaymentProviders();
|
|
|
|
|
|
const provider = paymentRegistry.getProvider(paymentType as PaymentType);
|
|
|
|
|
|
const queryResult = await provider.queryOrder(paymentTradeNo);
|
|
|
|
|
|
|
|
|
|
|
|
if (queryResult.status === 'paid') {
|
|
|
|
|
|
await confirmPayment({
|
|
|
|
|
|
orderId,
|
|
|
|
|
|
tradeNo: paymentTradeNo,
|
|
|
|
|
|
paidAmount: queryResult.amount,
|
|
|
|
|
|
providerName: provider.name,
|
|
|
|
|
|
});
|
|
|
|
|
|
console.log(`Order ${orderId} was paid during cancel (${operator}), processed as success`);
|
|
|
|
|
|
return 'already_paid';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (provider.cancelPayment) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await provider.cancelPayment(paymentTradeNo);
|
|
|
|
|
|
} catch (cancelErr) {
|
|
|
|
|
|
console.warn(`Failed to cancel payment for order ${orderId}:`, cancelErr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (platformErr) {
|
|
|
|
|
|
console.warn(`Platform check failed for order ${orderId}, cancelling locally:`, platformErr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. DB 更新 (WHERE status='PENDING' 保证幂等)
|
2026-03-01 03:04:24 +08:00
|
|
|
|
const result = await prisma.order.updateMany({
|
2026-03-06 17:34:42 +08:00
|
|
|
|
where: { id: orderId, status: ORDER_STATUS.PENDING },
|
2026-03-01 18:44:49 +08:00
|
|
|
|
data: { status: finalStatus, updatedAt: new Date() },
|
2026-03-01 03:04:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-01 18:44:49 +08:00
|
|
|
|
// 3. 审计日志
|
|
|
|
|
|
if (result.count > 0) {
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId,
|
2026-03-06 17:34:42 +08:00
|
|
|
|
action: finalStatus === ORDER_STATUS.EXPIRED ? 'ORDER_EXPIRED' : 'ORDER_CANCELLED',
|
2026-03-01 18:44:49 +08:00
|
|
|
|
detail: auditDetail,
|
|
|
|
|
|
operator,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 18:44:49 +08:00
|
|
|
|
return 'cancelled';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 18:33:57 +08:00
|
|
|
|
export async function cancelOrder(orderId: string, userId: number, locale: Locale = 'zh'): Promise<CancelOutcome> {
|
2026-03-01 18:44:49 +08:00
|
|
|
|
const order = await prisma.order.findUnique({
|
|
|
|
|
|
where: { id: orderId },
|
|
|
|
|
|
select: { id: true, userId: true, status: true, paymentTradeNo: true, paymentType: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-09 18:33:57 +08:00
|
|
|
|
if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
|
|
|
|
|
if (order.userId !== userId) throw new OrderError('FORBIDDEN', message(locale, '无权操作该订单', 'Forbidden'), 403);
|
|
|
|
|
|
if (order.status !== ORDER_STATUS.PENDING)
|
|
|
|
|
|
throw new OrderError('INVALID_STATUS', message(locale, '订单当前状态不可取消', 'Order cannot be cancelled'), 400);
|
2026-03-01 18:44:49 +08:00
|
|
|
|
|
|
|
|
|
|
return cancelOrderCore({
|
|
|
|
|
|
orderId: order.id,
|
|
|
|
|
|
paymentTradeNo: order.paymentTradeNo,
|
|
|
|
|
|
paymentType: order.paymentType,
|
2026-03-06 17:34:42 +08:00
|
|
|
|
finalStatus: ORDER_STATUS.CANCELLED,
|
2026-03-01 18:44:49 +08:00
|
|
|
|
operator: `user:${userId}`,
|
2026-03-09 18:33:57 +08:00
|
|
|
|
auditDetail: message(locale, '用户取消订单', 'User cancelled order'),
|
2026-03-01 03:04:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 18:33:57 +08:00
|
|
|
|
export async function adminCancelOrder(orderId: string, locale: Locale = 'zh'): Promise<CancelOutcome> {
|
2026-03-01 18:44:49 +08:00
|
|
|
|
const order = await prisma.order.findUnique({
|
|
|
|
|
|
where: { id: orderId },
|
|
|
|
|
|
select: { id: true, status: true, paymentTradeNo: true, paymentType: true },
|
2026-03-01 03:04:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-09 18:33:57 +08:00
|
|
|
|
if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
|
|
|
|
|
if (order.status !== ORDER_STATUS.PENDING)
|
|
|
|
|
|
throw new OrderError('INVALID_STATUS', message(locale, '订单当前状态不可取消', 'Order cannot be cancelled'), 400);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
|
2026-03-01 18:44:49 +08:00
|
|
|
|
return cancelOrderCore({
|
|
|
|
|
|
orderId: order.id,
|
|
|
|
|
|
paymentTradeNo: order.paymentTradeNo,
|
|
|
|
|
|
paymentType: order.paymentType,
|
2026-03-06 17:34:42 +08:00
|
|
|
|
finalStatus: ORDER_STATUS.CANCELLED,
|
2026-03-01 18:44:49 +08:00
|
|
|
|
operator: 'admin',
|
2026-03-09 18:33:57 +08:00
|
|
|
|
auditDetail: message(locale, '管理员取消订单', 'Admin cancelled order'),
|
2026-03-01 03:04:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:58:08 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Provider-agnostic: confirm a payment and trigger recharge.
|
|
|
|
|
|
* Called by any provider's webhook/notify handler after verification.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function confirmPayment(input: {
|
|
|
|
|
|
orderId: string;
|
|
|
|
|
|
tradeNo: string;
|
|
|
|
|
|
paidAmount: number;
|
|
|
|
|
|
providerName: string;
|
|
|
|
|
|
}): Promise<boolean> {
|
2026-03-01 03:04:24 +08:00
|
|
|
|
const order = await prisma.order.findUnique({
|
2026-03-01 17:58:08 +08:00
|
|
|
|
where: { id: input.orderId },
|
2026-03-01 03:04:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
if (!order) {
|
2026-03-01 17:58:08 +08:00
|
|
|
|
console.error(`${input.providerName} notify: order not found:`, input.orderId);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let paidAmount: Prisma.Decimal;
|
|
|
|
|
|
try {
|
2026-03-01 17:58:08 +08:00
|
|
|
|
paidAmount = new Prisma.Decimal(input.paidAmount.toFixed(2));
|
2026-03-01 03:04:24 +08:00
|
|
|
|
} catch {
|
2026-03-01 17:58:08 +08:00
|
|
|
|
console.error(`${input.providerName} notify: invalid amount:`, input.paidAmount);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (paidAmount.lte(0)) {
|
2026-03-01 17:58:08 +08:00
|
|
|
|
console.error(`${input.providerName} notify: non-positive amount:`, input.paidAmount);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-03-03 22:00:44 +08:00
|
|
|
|
const expectedAmount = order.payAmount ?? order.amount;
|
|
|
|
|
|
if (!paidAmount.equals(expectedAmount)) {
|
2026-03-06 13:57:52 +08:00
|
|
|
|
const diff = paidAmount.minus(expectedAmount).abs();
|
|
|
|
|
|
if (diff.gt(new Prisma.Decimal('0.01'))) {
|
|
|
|
|
|
// 写审计日志
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId: order.id,
|
|
|
|
|
|
action: 'PAYMENT_AMOUNT_MISMATCH',
|
|
|
|
|
|
detail: JSON.stringify({
|
|
|
|
|
|
expected: expectedAmount.toString(),
|
|
|
|
|
|
paid: paidAmount.toString(),
|
|
|
|
|
|
diff: diff.toString(),
|
|
|
|
|
|
tradeNo: input.tradeNo,
|
|
|
|
|
|
}),
|
|
|
|
|
|
operator: input.providerName,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
console.error(
|
|
|
|
|
|
`${input.providerName} notify: amount mismatch beyond threshold`,
|
|
|
|
|
|
`expected=${expectedAmount.toString()}, paid=${paidAmount.toString()}, diff=${diff.toString()}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-03-01 17:58:08 +08:00
|
|
|
|
console.warn(
|
2026-03-06 13:57:52 +08:00
|
|
|
|
`${input.providerName} notify: minor amount difference (rounding)`,
|
2026-03-03 22:00:44 +08:00
|
|
|
|
expectedAmount.toString(),
|
2026-03-01 17:58:08 +08:00
|
|
|
|
paidAmount.toString(),
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 04:15:48 +08:00
|
|
|
|
// 只接受 PENDING 状态,或过期不超过 5 分钟的 EXPIRED 订单(支付在过期边缘完成的宽限窗口)
|
|
|
|
|
|
const graceDeadline = new Date(Date.now() - 5 * 60 * 1000);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
const result = await prisma.order.updateMany({
|
|
|
|
|
|
where: {
|
|
|
|
|
|
id: order.id,
|
2026-03-10 18:20:36 +08:00
|
|
|
|
OR: [{ status: ORDER_STATUS.PENDING }, { status: ORDER_STATUS.EXPIRED, updatedAt: { gte: graceDeadline } }],
|
2026-03-01 03:04:24 +08:00
|
|
|
|
},
|
|
|
|
|
|
data: {
|
2026-03-06 17:34:42 +08:00
|
|
|
|
status: ORDER_STATUS.PAID,
|
2026-03-07 04:15:48 +08:00
|
|
|
|
payAmount: paidAmount,
|
2026-03-01 17:58:08 +08:00
|
|
|
|
paymentTradeNo: input.tradeNo,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
paidAt: new Date(),
|
|
|
|
|
|
failedAt: null,
|
|
|
|
|
|
failedReason: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (result.count === 0) {
|
2026-03-06 13:57:52 +08:00
|
|
|
|
// 重新查询当前状态,区分「已成功」和「需重试」
|
|
|
|
|
|
const current = await prisma.order.findUnique({
|
|
|
|
|
|
where: { id: order.id },
|
|
|
|
|
|
select: { status: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!current) return true;
|
|
|
|
|
|
|
|
|
|
|
|
// 已完成或已退款 — 告知支付平台成功
|
2026-03-06 19:00:16 +08:00
|
|
|
|
if (current.status === ORDER_STATUS.COMPLETED || current.status === ORDER_STATUS.REFUNDED) {
|
2026-03-06 13:57:52 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// FAILED 状态 — 之前充值失败,利用重试通知自动重试充值
|
2026-03-06 19:00:16 +08:00
|
|
|
|
if (current.status === ORDER_STATUS.FAILED) {
|
2026-03-06 13:57:52 +08:00
|
|
|
|
try {
|
2026-03-13 19:06:25 +08:00
|
|
|
|
await executeFulfillment(order.id);
|
2026-03-06 13:57:52 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
} catch (err) {
|
2026-03-13 19:06:25 +08:00
|
|
|
|
console.error('Fulfillment retry failed for order:', order.id, err);
|
2026-03-06 13:57:52 +08:00
|
|
|
|
return false; // 让支付平台继续重试
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// PAID / RECHARGING — 正在处理中,让支付平台稍后重试
|
2026-03-06 19:00:16 +08:00
|
|
|
|
if (current.status === ORDER_STATUS.PAID || current.status === ORDER_STATUS.RECHARGING) {
|
2026-03-06 13:57:52 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 其他状态(CANCELLED 等)— 不应该出现,返回 true 停止重试
|
2026-03-01 03:04:24 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId: order.id,
|
|
|
|
|
|
action: 'ORDER_PAID',
|
|
|
|
|
|
detail: JSON.stringify({
|
|
|
|
|
|
previous_status: order.status,
|
2026-03-01 17:58:08 +08:00
|
|
|
|
trade_no: input.tradeNo,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
expected_amount: order.amount.toString(),
|
|
|
|
|
|
paid_amount: paidAmount.toString(),
|
|
|
|
|
|
}),
|
2026-03-01 17:58:08 +08:00
|
|
|
|
operator: input.providerName,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-13 19:06:25 +08:00
|
|
|
|
await executeFulfillment(order.id);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
} catch (err) {
|
2026-03-13 19:06:25 +08:00
|
|
|
|
console.error('Fulfillment failed for order:', order.id, err);
|
2026-03-06 13:57:52 +08:00
|
|
|
|
return false;
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:58:08 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Handle a verified payment notification from any provider.
|
|
|
|
|
|
* The caller (webhook route) is responsible for verifying the notification
|
|
|
|
|
|
* via provider.verifyNotification() before calling this function.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function handlePaymentNotify(notification: PaymentNotification, providerName: string): Promise<boolean> {
|
|
|
|
|
|
if (notification.status !== 'success') {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return confirmPayment({
|
|
|
|
|
|
orderId: notification.orderId,
|
|
|
|
|
|
tradeNo: notification.tradeNo,
|
|
|
|
|
|
paidAmount: notification.amount,
|
|
|
|
|
|
providerName,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 19:06:25 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 统一履约入口 — 根据 orderType 分派到余额充值或订阅分配。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function executeFulfillment(orderId: string): Promise<void> {
|
|
|
|
|
|
const order = await prisma.order.findUnique({
|
|
|
|
|
|
where: { id: orderId },
|
|
|
|
|
|
select: { orderType: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
|
|
|
|
|
|
|
|
|
|
|
if (order.orderType === 'subscription') {
|
|
|
|
|
|
await executeSubscriptionFulfillment(orderId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await executeRecharge(orderId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 订阅履约 — 支付成功后调用 Sub2API 分配订阅。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function executeSubscriptionFulfillment(orderId: string): Promise<void> {
|
|
|
|
|
|
const order = await prisma.order.findUnique({ where: { id: orderId } });
|
|
|
|
|
|
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
|
|
|
|
|
if (order.status === ORDER_STATUS.COMPLETED) return;
|
|
|
|
|
|
if (isRefundStatus(order.status)) {
|
|
|
|
|
|
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot fulfill', 400);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (order.status !== ORDER_STATUS.PAID && order.status !== ORDER_STATUS.FAILED) {
|
|
|
|
|
|
throw new OrderError('INVALID_STATUS', `Order cannot fulfill in status ${order.status}`, 400);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!order.subscriptionGroupId || !order.subscriptionDays) {
|
|
|
|
|
|
throw new OrderError('INVALID_STATUS', 'Missing subscription info on order', 400);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CAS 锁
|
|
|
|
|
|
const lockResult = await prisma.order.updateMany({
|
|
|
|
|
|
where: { id: orderId, status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.FAILED] } },
|
|
|
|
|
|
data: { status: ORDER_STATUS.RECHARGING },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (lockResult.count === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 校验分组是否仍然存在
|
|
|
|
|
|
const group = await getGroup(order.subscriptionGroupId);
|
|
|
|
|
|
if (!group || group.status !== 'active') {
|
|
|
|
|
|
throw new Error(`Subscription group ${order.subscriptionGroupId} no longer exists or inactive`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 21:19:22 +08:00
|
|
|
|
await createAndRedeem(
|
|
|
|
|
|
order.rechargeCode,
|
|
|
|
|
|
Number(order.amount),
|
2026-03-13 19:06:25 +08:00
|
|
|
|
order.userId,
|
|
|
|
|
|
`sub2apipay subscription order:${orderId}`,
|
2026-03-13 21:19:22 +08:00
|
|
|
|
{
|
|
|
|
|
|
type: 'subscription',
|
|
|
|
|
|
groupId: order.subscriptionGroupId,
|
|
|
|
|
|
validityDays: order.subscriptionDays,
|
|
|
|
|
|
},
|
2026-03-13 19:06:25 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.order.updateMany({
|
|
|
|
|
|
where: { id: orderId, status: ORDER_STATUS.RECHARGING },
|
|
|
|
|
|
data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId,
|
|
|
|
|
|
action: 'SUBSCRIPTION_SUCCESS',
|
|
|
|
|
|
detail: JSON.stringify({
|
|
|
|
|
|
groupId: order.subscriptionGroupId,
|
|
|
|
|
|
days: order.subscriptionDays,
|
|
|
|
|
|
amount: Number(order.amount),
|
|
|
|
|
|
}),
|
|
|
|
|
|
operator: 'system',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
|
|
|
|
const isGroupGone = reason.includes('no longer exists');
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.order.update({
|
|
|
|
|
|
where: { id: orderId },
|
|
|
|
|
|
data: {
|
|
|
|
|
|
status: ORDER_STATUS.FAILED,
|
|
|
|
|
|
failedAt: new Date(),
|
|
|
|
|
|
failedReason: isGroupGone
|
|
|
|
|
|
? `SUBSCRIPTION_GROUP_GONE: ${reason}`
|
|
|
|
|
|
: reason,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId,
|
|
|
|
|
|
action: 'SUBSCRIPTION_FAILED',
|
|
|
|
|
|
detail: reason,
|
|
|
|
|
|
operator: 'system',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 03:04:24 +08:00
|
|
|
|
export async function executeRecharge(orderId: string): Promise<void> {
|
|
|
|
|
|
const order = await prisma.order.findUnique({ where: { id: orderId } });
|
|
|
|
|
|
if (!order) {
|
|
|
|
|
|
throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
|
|
|
|
|
}
|
2026-03-06 17:34:42 +08:00
|
|
|
|
if (order.status === ORDER_STATUS.COMPLETED) {
|
2026-03-01 03:04:24 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isRefundStatus(order.status)) {
|
|
|
|
|
|
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot recharge', 400);
|
|
|
|
|
|
}
|
2026-03-06 17:34:42 +08:00
|
|
|
|
if (order.status !== ORDER_STATUS.PAID && order.status !== ORDER_STATUS.FAILED) {
|
2026-03-01 03:04:24 +08:00
|
|
|
|
throw new OrderError('INVALID_STATUS', `Order cannot recharge in status ${order.status}`, 400);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 13:57:52 +08:00
|
|
|
|
// 原子 CAS:将状态从 PAID/FAILED → RECHARGING,防止并发竞态
|
|
|
|
|
|
const lockResult = await prisma.order.updateMany({
|
2026-03-06 19:00:16 +08:00
|
|
|
|
where: { id: orderId, status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.FAILED] } },
|
|
|
|
|
|
data: { status: ORDER_STATUS.RECHARGING },
|
2026-03-06 13:57:52 +08:00
|
|
|
|
});
|
|
|
|
|
|
if (lockResult.count === 0) {
|
|
|
|
|
|
// 另一个并发请求已经在处理
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 03:04:24 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await createAndRedeem(
|
|
|
|
|
|
order.rechargeCode,
|
|
|
|
|
|
Number(order.amount),
|
|
|
|
|
|
order.userId,
|
|
|
|
|
|
`sub2apipay recharge order:${orderId}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-07 04:15:48 +08:00
|
|
|
|
await prisma.order.updateMany({
|
|
|
|
|
|
where: { id: orderId, status: ORDER_STATUS.RECHARGING },
|
2026-03-06 17:34:42 +08:00
|
|
|
|
data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() },
|
2026-03-01 03:04:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId,
|
|
|
|
|
|
action: 'RECHARGE_SUCCESS',
|
|
|
|
|
|
detail: JSON.stringify({ rechargeCode: order.rechargeCode, amount: Number(order.amount) }),
|
|
|
|
|
|
operator: 'system',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
await prisma.order.update({
|
|
|
|
|
|
where: { id: orderId },
|
|
|
|
|
|
data: {
|
2026-03-06 17:34:42 +08:00
|
|
|
|
status: ORDER_STATUS.FAILED,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
failedAt: new Date(),
|
|
|
|
|
|
failedReason: error instanceof Error ? error.message : String(error),
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId,
|
|
|
|
|
|
action: 'RECHARGE_FAILED',
|
|
|
|
|
|
detail: error instanceof Error ? error.message : String(error),
|
|
|
|
|
|
operator: 'system',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 18:33:57 +08:00
|
|
|
|
function assertRetryAllowed(order: { status: string; paidAt: Date | null }, locale: Locale): void {
|
2026-03-01 03:04:24 +08:00
|
|
|
|
if (!order.paidAt) {
|
2026-03-10 18:20:36 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'INVALID_STATUS',
|
|
|
|
|
|
message(locale, '订单未支付,不允许重试', 'Order is not paid, retry denied'),
|
|
|
|
|
|
400,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isRefundStatus(order.status)) {
|
2026-03-10 18:20:36 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'INVALID_STATUS',
|
|
|
|
|
|
message(locale, '退款相关订单不允许重试', 'Refund-related order cannot retry'),
|
|
|
|
|
|
400,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 17:34:42 +08:00
|
|
|
|
if (order.status === ORDER_STATUS.FAILED || order.status === ORDER_STATUS.PAID) {
|
2026-03-01 03:04:24 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 17:34:42 +08:00
|
|
|
|
if (order.status === ORDER_STATUS.RECHARGING) {
|
2026-03-10 18:20:36 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'CONFLICT',
|
|
|
|
|
|
message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'),
|
|
|
|
|
|
409,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 17:34:42 +08:00
|
|
|
|
if (order.status === ORDER_STATUS.COMPLETED) {
|
2026-03-09 18:33:57 +08:00
|
|
|
|
throw new OrderError('INVALID_STATUS', message(locale, '订单已完成', 'Order already completed'), 400);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 18:20:36 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'INVALID_STATUS',
|
|
|
|
|
|
message(locale, '仅已支付和失败订单允许重试', 'Only paid and failed orders can retry'),
|
|
|
|
|
|
400,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 18:33:57 +08:00
|
|
|
|
export async function retryRecharge(orderId: string, locale: Locale = 'zh'): Promise<void> {
|
2026-03-01 03:04:24 +08:00
|
|
|
|
const order = await prisma.order.findUnique({
|
|
|
|
|
|
where: { id: orderId },
|
|
|
|
|
|
select: {
|
|
|
|
|
|
id: true,
|
|
|
|
|
|
status: true,
|
|
|
|
|
|
paidAt: true,
|
|
|
|
|
|
completedAt: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!order) {
|
2026-03-09 18:33:57 +08:00
|
|
|
|
throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 18:33:57 +08:00
|
|
|
|
assertRetryAllowed(order, locale);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
|
|
|
|
|
|
const result = await prisma.order.updateMany({
|
|
|
|
|
|
where: {
|
|
|
|
|
|
id: orderId,
|
2026-03-06 17:34:42 +08:00
|
|
|
|
status: { in: [ORDER_STATUS.FAILED, ORDER_STATUS.PAID] },
|
2026-03-01 03:04:24 +08:00
|
|
|
|
paidAt: { not: null },
|
|
|
|
|
|
},
|
2026-03-06 17:34:42 +08:00
|
|
|
|
data: { status: ORDER_STATUS.PAID, failedAt: null, failedReason: null },
|
2026-03-01 03:04:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (result.count === 0) {
|
|
|
|
|
|
const latest = await prisma.order.findUnique({
|
|
|
|
|
|
where: { id: orderId },
|
|
|
|
|
|
select: {
|
|
|
|
|
|
status: true,
|
|
|
|
|
|
paidAt: true,
|
|
|
|
|
|
completedAt: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!latest) {
|
2026-03-09 18:33:57 +08:00
|
|
|
|
throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const derived = deriveOrderState(latest);
|
2026-03-06 17:34:42 +08:00
|
|
|
|
if (derived.rechargeStatus === 'recharging' || latest.status === ORDER_STATUS.PAID) {
|
2026-03-10 18:20:36 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'CONFLICT',
|
|
|
|
|
|
message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'),
|
|
|
|
|
|
409,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (derived.rechargeStatus === 'success') {
|
2026-03-09 18:33:57 +08:00
|
|
|
|
throw new OrderError('INVALID_STATUS', message(locale, '订单已完成', 'Order already completed'), 400);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isRefundStatus(latest.status)) {
|
2026-03-10 18:20:36 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'INVALID_STATUS',
|
|
|
|
|
|
message(locale, '退款相关订单不允许重试', 'Refund-related order cannot retry'),
|
|
|
|
|
|
400,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 18:20:36 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'CONFLICT',
|
|
|
|
|
|
message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'),
|
|
|
|
|
|
409,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId,
|
|
|
|
|
|
action: 'RECHARGE_RETRY',
|
2026-03-09 18:33:57 +08:00
|
|
|
|
detail: message(locale, '管理员手动重试充值', 'Admin manual retry recharge'),
|
2026-03-01 03:04:24 +08:00
|
|
|
|
operator: 'admin',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-13 19:06:25 +08:00
|
|
|
|
await executeFulfillment(orderId);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface RefundInput {
|
|
|
|
|
|
orderId: string;
|
|
|
|
|
|
reason?: string;
|
|
|
|
|
|
force?: boolean;
|
2026-03-09 18:33:57 +08:00
|
|
|
|
locale?: Locale;
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface RefundResult {
|
|
|
|
|
|
success: boolean;
|
|
|
|
|
|
warning?: string;
|
|
|
|
|
|
requireForce?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
2026-03-09 18:33:57 +08:00
|
|
|
|
const locale = input.locale ?? 'zh';
|
2026-03-01 03:04:24 +08:00
|
|
|
|
const order = await prisma.order.findUnique({ where: { id: input.orderId } });
|
2026-03-09 18:33:57 +08:00
|
|
|
|
if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
2026-03-06 17:34:42 +08:00
|
|
|
|
if (order.status !== ORDER_STATUS.COMPLETED) {
|
2026-03-10 18:20:36 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'INVALID_STATUS',
|
|
|
|
|
|
message(locale, '仅已完成订单允许退款', 'Only completed orders can be refunded'),
|
|
|
|
|
|
400,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:00:44 +08:00
|
|
|
|
const rechargeAmount = Number(order.amount);
|
|
|
|
|
|
const refundAmount = Number(order.payAmount ?? order.amount);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
|
|
|
|
|
|
if (!input.force) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const user = await getUser(order.userId);
|
2026-03-03 22:00:44 +08:00
|
|
|
|
if (user.balance < rechargeAmount) {
|
2026-03-01 03:04:24 +08:00
|
|
|
|
return {
|
|
|
|
|
|
success: false,
|
2026-03-09 18:33:57 +08:00
|
|
|
|
warning: message(
|
|
|
|
|
|
locale,
|
|
|
|
|
|
`用户余额 ${user.balance} 小于需退款的充值金额 ${rechargeAmount}`,
|
|
|
|
|
|
`User balance ${user.balance} is lower than refund ${rechargeAmount}`,
|
|
|
|
|
|
),
|
2026-03-01 03:04:24 +08:00
|
|
|
|
requireForce: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: false,
|
2026-03-09 18:33:57 +08:00
|
|
|
|
warning: message(locale, '无法获取用户余额,请使用 force=true', 'Cannot fetch user balance, use force=true'),
|
2026-03-01 03:04:24 +08:00
|
|
|
|
requireForce: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const lockResult = await prisma.order.updateMany({
|
2026-03-06 17:34:42 +08:00
|
|
|
|
where: { id: input.orderId, status: ORDER_STATUS.COMPLETED },
|
|
|
|
|
|
data: { status: ORDER_STATUS.REFUNDING },
|
2026-03-01 03:04:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
if (lockResult.count === 0) {
|
2026-03-10 18:20:36 +08:00
|
|
|
|
throw new OrderError(
|
|
|
|
|
|
'CONFLICT',
|
|
|
|
|
|
message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'),
|
|
|
|
|
|
409,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-07 04:15:48 +08:00
|
|
|
|
// 1. 先扣减用户余额(安全方向:先扣后退)
|
2026-03-05 23:10:44 +08:00
|
|
|
|
await subtractBalance(
|
|
|
|
|
|
order.userId,
|
|
|
|
|
|
rechargeAmount,
|
|
|
|
|
|
`sub2apipay refund order:${order.id}`,
|
|
|
|
|
|
`sub2apipay:refund:${order.id}`,
|
|
|
|
|
|
);
|
2026-03-01 03:04:24 +08:00
|
|
|
|
|
2026-03-07 04:15:48 +08:00
|
|
|
|
// 2. 调用支付网关退款
|
|
|
|
|
|
if (order.paymentTradeNo) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
initPaymentProviders();
|
|
|
|
|
|
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
|
|
|
|
|
|
await provider.refund({
|
|
|
|
|
|
tradeNo: order.paymentTradeNo,
|
|
|
|
|
|
orderId: order.id,
|
|
|
|
|
|
amount: refundAmount,
|
|
|
|
|
|
reason: input.reason,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (gatewayError) {
|
|
|
|
|
|
// 3. 网关退款失败 — 恢复已扣减的余额
|
|
|
|
|
|
try {
|
|
|
|
|
|
await addBalance(
|
|
|
|
|
|
order.userId,
|
|
|
|
|
|
rechargeAmount,
|
|
|
|
|
|
`sub2apipay refund rollback order:${order.id}`,
|
|
|
|
|
|
`sub2apipay:refund-rollback:${order.id}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (rollbackError) {
|
|
|
|
|
|
// 余额恢复也失败,记录审计日志,需人工介入
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId: input.orderId,
|
|
|
|
|
|
action: 'REFUND_ROLLBACK_FAILED',
|
|
|
|
|
|
detail: JSON.stringify({
|
|
|
|
|
|
gatewayError: gatewayError instanceof Error ? gatewayError.message : String(gatewayError),
|
|
|
|
|
|
rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
|
|
|
|
|
|
rechargeAmount,
|
|
|
|
|
|
}),
|
|
|
|
|
|
operator: 'admin',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
throw gatewayError;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 03:04:24 +08:00
|
|
|
|
await prisma.order.update({
|
|
|
|
|
|
where: { id: input.orderId },
|
|
|
|
|
|
data: {
|
2026-03-06 17:34:42 +08:00
|
|
|
|
status: ORDER_STATUS.REFUNDED,
|
2026-03-03 22:00:44 +08:00
|
|
|
|
refundAmount: new Prisma.Decimal(refundAmount.toFixed(2)),
|
2026-03-01 03:04:24 +08:00
|
|
|
|
refundReason: input.reason || null,
|
|
|
|
|
|
refundAt: new Date(),
|
|
|
|
|
|
forceRefund: input.force || false,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId: input.orderId,
|
|
|
|
|
|
action: 'REFUND_SUCCESS',
|
2026-03-03 22:00:44 +08:00
|
|
|
|
detail: JSON.stringify({ rechargeAmount, refundAmount, reason: input.reason, force: input.force }),
|
2026-03-01 03:04:24 +08:00
|
|
|
|
operator: 'admin',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
await prisma.order.update({
|
|
|
|
|
|
where: { id: input.orderId },
|
|
|
|
|
|
data: {
|
2026-03-06 17:34:42 +08:00
|
|
|
|
status: ORDER_STATUS.REFUND_FAILED,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
failedAt: new Date(),
|
|
|
|
|
|
failedReason: error instanceof Error ? error.message : String(error),
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
orderId: input.orderId,
|
|
|
|
|
|
action: 'REFUND_FAILED',
|
|
|
|
|
|
detail: error instanceof Error ? error.message : String(error),
|
|
|
|
|
|
operator: 'admin',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export class OrderError extends Error {
|
|
|
|
|
|
code: string;
|
|
|
|
|
|
statusCode: number;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(code: string, message: string, statusCode: number = 400) {
|
|
|
|
|
|
super(message);
|
|
|
|
|
|
this.name = 'OrderError';
|
|
|
|
|
|
this.code = code;
|
|
|
|
|
|
this.statusCode = statusCode;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|