feat: migrate payment provider to easy-pay, add order history and refund support

- Replace zpay with easy-pay payment provider (new lib/easy-pay/ module)
- Add order history page for users (pay/orders)
- Add GET /api/orders/my endpoint to list user's own orders
- Add GET /api/users/[id] endpoint for sub2api user lookup
- Add order status tracking module (lib/order/status.ts)
- Update config to support easy-pay credentials (merchant ID, key, gateway)
- Update PaymentForm and PaymentQRCode components for easy-pay flow
- Update pay page and admin page with new order management UI
- Update order service to support easy-pay, cancellation, and refund
This commit is contained in:
erio
2026-03-01 03:04:24 +08:00
commit d5719bf213
73 changed files with 10616 additions and 0 deletions

19
src/lib/admin-auth.ts Normal file
View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { getEnv } from '@/lib/config';
import crypto from 'crypto';
export function verifyAdminToken(request: NextRequest): boolean {
const token = request.nextUrl.searchParams.get('token');
if (!token) return false;
const env = getEnv();
const expected = Buffer.from(env.ADMIN_TOKEN);
const received = Buffer.from(token);
if (expected.length !== received.length) return false;
return crypto.timingSafeEqual(expected, received);
}
export function unauthorizedResponse() {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}

139
src/lib/config.ts Normal file
View File

@@ -0,0 +1,139 @@
import { z } from 'zod';
const optionalTrimmedString = z.preprocess((value) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed === '' ? undefined : trimmed;
}, z.string().optional());
const rawEnvSchema = z.object({
DATABASE_URL: z.string().min(1),
SUB2API_BASE_URL: z.string().url(),
SUB2API_ADMIN_API_KEY: z.string().min(1),
EASY_PAY_PID: optionalTrimmedString,
EASY_PAY_PKEY: optionalTrimmedString,
EASY_PAY_API_BASE: optionalTrimmedString,
EASY_PAY_NOTIFY_URL: optionalTrimmedString,
EASY_PAY_RETURN_URL: optionalTrimmedString,
EASY_PAY_CID: optionalTrimmedString,
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
EASY_PAY_CID_WXPAY: optionalTrimmedString,
ZPAY_PID: optionalTrimmedString,
ZPAY_PKEY: optionalTrimmedString,
ZPAY_API_BASE: optionalTrimmedString,
ZPAY_NOTIFY_URL: optionalTrimmedString,
ZPAY_RETURN_URL: optionalTrimmedString,
ZPAY_CID: optionalTrimmedString,
ZPAY_CID_ALIPAY: optionalTrimmedString,
ZPAY_CID_WXPAY: optionalTrimmedString,
ENABLED_PAYMENT_TYPES: z.string().default('alipay,wxpay').transform(v => v.split(',').map(s => s.trim())),
ORDER_TIMEOUT_MINUTES: z.string().default('5').transform(Number).pipe(z.number().int().positive()),
MIN_RECHARGE_AMOUNT: z.string().default('1').transform(Number).pipe(z.number().positive()),
MAX_RECHARGE_AMOUNT: z.string().default('10000').transform(Number).pipe(z.number().positive()),
PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'),
ADMIN_TOKEN: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: optionalTrimmedString,
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
});
const resolvedEnvSchema = z.object({
DATABASE_URL: z.string().min(1),
SUB2API_BASE_URL: z.string().url(),
SUB2API_ADMIN_API_KEY: z.string().min(1),
EASY_PAY_PID: z.string().min(1),
EASY_PAY_PKEY: z.string().min(1),
EASY_PAY_API_BASE: z.string().url(),
EASY_PAY_NOTIFY_URL: z.string().url(),
EASY_PAY_RETURN_URL: z.string().url(),
EASY_PAY_CID: optionalTrimmedString,
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
EASY_PAY_CID_WXPAY: optionalTrimmedString,
ENABLED_PAYMENT_TYPES: z.array(z.string()),
ORDER_TIMEOUT_MINUTES: z.number().int().positive(),
MIN_RECHARGE_AMOUNT: z.number().positive(),
MAX_RECHARGE_AMOUNT: z.number().positive(),
PRODUCT_NAME: z.string(),
ADMIN_TOKEN: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: optionalTrimmedString,
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
});
export type Env = z.infer<typeof resolvedEnvSchema>;
type RawEnv = z.infer<typeof rawEnvSchema>;
function pickRequired(raw: RawEnv, key: keyof RawEnv, fallbackKey: keyof RawEnv): string {
const value = raw[key] ?? raw[fallbackKey];
if (!value) {
throw new Error(`Missing required env: ${String(key)} (fallback: ${String(fallbackKey)})`);
}
return value;
}
function pickOptional(raw: RawEnv, key: keyof RawEnv, fallbackKey: keyof RawEnv): string | undefined {
return raw[key] ?? raw[fallbackKey] ?? undefined;
}
let cachedEnv: Env | null = null;
export function getEnv(): Env {
if (cachedEnv) return cachedEnv;
const parsed = rawEnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
throw new Error('Invalid environment variables');
}
const raw = parsed.data;
const resolved = {
DATABASE_URL: raw.DATABASE_URL,
SUB2API_BASE_URL: raw.SUB2API_BASE_URL,
SUB2API_ADMIN_API_KEY: raw.SUB2API_ADMIN_API_KEY,
EASY_PAY_PID: pickRequired(raw, 'EASY_PAY_PID', 'ZPAY_PID'),
EASY_PAY_PKEY: pickRequired(raw, 'EASY_PAY_PKEY', 'ZPAY_PKEY'),
EASY_PAY_API_BASE: pickRequired(raw, 'EASY_PAY_API_BASE', 'ZPAY_API_BASE'),
EASY_PAY_NOTIFY_URL: pickRequired(raw, 'EASY_PAY_NOTIFY_URL', 'ZPAY_NOTIFY_URL'),
EASY_PAY_RETURN_URL: pickRequired(raw, 'EASY_PAY_RETURN_URL', 'ZPAY_RETURN_URL'),
EASY_PAY_CID: pickOptional(raw, 'EASY_PAY_CID', 'ZPAY_CID'),
EASY_PAY_CID_ALIPAY: pickOptional(raw, 'EASY_PAY_CID_ALIPAY', 'ZPAY_CID_ALIPAY'),
EASY_PAY_CID_WXPAY: pickOptional(raw, 'EASY_PAY_CID_WXPAY', 'ZPAY_CID_WXPAY'),
ENABLED_PAYMENT_TYPES: raw.ENABLED_PAYMENT_TYPES,
ORDER_TIMEOUT_MINUTES: raw.ORDER_TIMEOUT_MINUTES,
MIN_RECHARGE_AMOUNT: raw.MIN_RECHARGE_AMOUNT,
MAX_RECHARGE_AMOUNT: raw.MAX_RECHARGE_AMOUNT,
PRODUCT_NAME: raw.PRODUCT_NAME,
ADMIN_TOKEN: raw.ADMIN_TOKEN,
NEXT_PUBLIC_APP_URL: raw.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: raw.NEXT_PUBLIC_PAY_HELP_IMAGE_URL,
NEXT_PUBLIC_PAY_HELP_TEXT: raw.NEXT_PUBLIC_PAY_HELP_TEXT,
};
const resolvedParsed = resolvedEnvSchema.safeParse(resolved);
if (!resolvedParsed.success) {
console.error('Invalid resolved env variables:', resolvedParsed.error.flatten().fieldErrors);
throw new Error('Invalid resolved env variables');
}
cachedEnv = resolvedParsed.data;
return cachedEnv;
}

14
src/lib/db.ts Normal file
View File

@@ -0,0 +1,14 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createPrismaClient() {
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/sub2apipay';
const adapter = new PrismaPg({ connectionString });
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma || createPrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

View File

@@ -0,0 +1,97 @@
import { getEnv } from '@/lib/config';
import { generateSign } from './sign';
import type { EasyPayCreateResponse, EasyPayQueryResponse, EasyPayRefundResponse } from './types';
export interface CreatePaymentOptions {
outTradeNo: string;
amount: string;
paymentType: 'alipay' | 'wxpay';
clientIp: string;
productName: string;
}
function normalizeCidList(cid?: string): string | undefined {
if (!cid) return undefined;
const normalized = cid
.split(',')
.map((item) => item.trim())
.filter(Boolean)
.join(',');
return normalized || undefined;
}
function resolveCid(paymentType: 'alipay' | 'wxpay'): string | undefined {
const env = getEnv();
if (paymentType === 'alipay') {
return normalizeCidList(env.EASY_PAY_CID_ALIPAY) || normalizeCidList(env.EASY_PAY_CID);
}
return normalizeCidList(env.EASY_PAY_CID_WXPAY) || normalizeCidList(env.EASY_PAY_CID);
}
export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPayCreateResponse> {
const env = getEnv();
const params: Record<string, string> = {
pid: env.EASY_PAY_PID,
type: opts.paymentType,
out_trade_no: opts.outTradeNo,
notify_url: env.EASY_PAY_NOTIFY_URL,
return_url: env.EASY_PAY_RETURN_URL,
name: opts.productName,
money: opts.amount,
clientip: opts.clientIp,
};
const cid = resolveCid(opts.paymentType);
if (cid) {
params.cid = cid;
}
const sign = generateSign(params, env.EASY_PAY_PKEY);
params.sign = sign;
params.sign_type = 'MD5';
const formData = new URLSearchParams(params);
const response = await fetch(`${env.EASY_PAY_API_BASE}/mapi.php`, {
method: 'POST',
body: formData,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const data = await response.json() as EasyPayCreateResponse;
if (data.code !== 1) {
throw new Error(`EasyPay create payment failed: ${data.msg || 'unknown error'}`);
}
return data;
}
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
const env = getEnv();
const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`;
const response = await fetch(url);
const data = await response.json() as EasyPayQueryResponse;
if (data.code !== 1) {
throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`);
}
return data;
}
export async function refund(tradeNo: string, outTradeNo: string, money: string): Promise<EasyPayRefundResponse> {
const env = getEnv();
const params = new URLSearchParams({
pid: env.EASY_PAY_PID,
key: env.EASY_PAY_PKEY,
trade_no: tradeNo,
out_trade_no: outTradeNo,
money,
});
const response = await fetch(`${env.EASY_PAY_API_BASE}/api.php?act=refund`, {
method: 'POST',
body: params,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const data = await response.json() as EasyPayRefundResponse;
if (data.code !== 1) {
throw new Error(`EasyPay refund failed: ${data.msg || 'unknown error'}`);
}
return data;
}

19
src/lib/easy-pay/sign.ts Normal file
View File

@@ -0,0 +1,19 @@
import crypto from 'crypto';
export function generateSign(params: Record<string, string>, pkey: string): string {
const filtered = Object.entries(params)
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
.sort(([a], [b]) => a.localeCompare(b));
const queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&');
const signStr = queryString + pkey;
return crypto.createHash('md5').update(signStr).digest('hex');
}
export function verifySign(params: Record<string, string>, pkey: string, sign: string): boolean {
const expected = generateSign(params, pkey);
if (expected.length !== sign.length) return false;
const a = Buffer.from(expected);
const b = Buffer.from(sign);
return crypto.timingSafeEqual(a, b);
}

57
src/lib/easy-pay/types.ts Normal file
View File

@@ -0,0 +1,57 @@
export interface EasyPayCreateParams {
pid: string;
cid?: string;
type: 'alipay' | 'wxpay';
out_trade_no: string;
notify_url: string;
name: string;
money: string;
clientip: string;
return_url: string;
sign?: string;
sign_type?: string;
}
export interface EasyPayCreateResponse {
code: number;
msg?: string;
trade_no: string;
O_id?: string;
payurl?: string;
qrcode?: string;
img?: string;
}
export interface EasyPayNotifyParams {
pid: string;
name: string;
money: string;
out_trade_no: string;
trade_no: string;
param?: string;
trade_status: string;
type: string;
sign: string;
sign_type: string;
}
export interface EasyPayQueryResponse {
code: number;
msg?: string;
trade_no: string;
out_trade_no: string;
type: string;
pid: string;
addtime: string;
endtime: string;
name: string;
money: string;
status: number;
param?: string;
buyer?: string;
}
export interface EasyPayRefundResponse {
code: number;
msg: string;
}

View File

@@ -0,0 +1,6 @@
export function generateRechargeCode(orderId: string): string {
const prefix = 's2p_';
const maxIdLength = 32 - prefix.length; // 28
const truncatedId = orderId.slice(0, maxIdLength);
return `${prefix}${truncatedId}`;
}

509
src/lib/order/service.ts Normal file
View File

@@ -0,0 +1,509 @@
import { prisma } from '@/lib/db';
import { getEnv } from '@/lib/config';
import { generateRechargeCode } from './code-gen';
import { createPayment } from '@/lib/easy-pay/client';
import { verifySign } from '@/lib/easy-pay/sign';
import { refund as easyPayRefund } from '@/lib/easy-pay/client';
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
import { Prisma } from '@prisma/client';
import type { ZPayNotifyParams } from '@/lib/easy-pay/types';
import { deriveOrderState, isRefundStatus } from './status';
const MAX_PENDING_ORDERS = 3;
export interface CreateOrderInput {
userId: number;
amount: number;
paymentType: 'alipay' | 'wxpay';
clientIp: string;
}
export interface CreateOrderResult {
orderId: string;
amount: number;
status: string;
paymentType: 'alipay' | 'wxpay';
userName: string;
userBalance: number;
payUrl?: string | null;
qrCode?: string | null;
expiresAt: Date;
}
export async function createOrder(input: CreateOrderInput): Promise<CreateOrderResult> {
const env = getEnv();
const user = await getUser(input.userId);
if (user.status !== 'active') {
throw new OrderError('USER_INACTIVE', 'User account is disabled', 422);
}
const pendingCount = await prisma.order.count({
where: { userId: input.userId, status: 'PENDING' },
});
if (pendingCount >= MAX_PENDING_ORDERS) {
throw new OrderError('TOO_MANY_PENDING', `Too many pending orders (${MAX_PENDING_ORDERS})`, 429);
}
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
const order = await prisma.order.create({
data: {
userId: input.userId,
userEmail: user.email,
userName: user.username,
amount: new Prisma.Decimal(input.amount.toFixed(2)),
rechargeCode: '',
status: 'PENDING',
paymentType: input.paymentType,
expiresAt,
clientIp: input.clientIp,
},
});
const rechargeCode = generateRechargeCode(order.id);
await prisma.order.update({
where: { id: order.id },
data: { rechargeCode },
});
try {
const easyPayResult = await createPayment({
outTradeNo: order.id,
amount: input.amount.toFixed(2),
paymentType: input.paymentType,
clientIp: input.clientIp,
productName: `${env.PRODUCT_NAME} ${input.amount.toFixed(2)} CNY`,
});
await prisma.order.update({
where: { id: order.id },
data: {
zpayTradeNo: easyPayResult.trade_no,
payUrl: easyPayResult.payurl || null,
qrCode: easyPayResult.qrcode || null,
},
});
await prisma.auditLog.create({
data: {
orderId: order.id,
action: 'ORDER_CREATED',
detail: JSON.stringify({ userId: input.userId, amount: input.amount, paymentType: input.paymentType }),
operator: `user:${input.userId}`,
},
});
return {
orderId: order.id,
amount: input.amount,
status: 'PENDING',
paymentType: input.paymentType,
userName: user.username,
userBalance: user.balance,
payUrl: easyPayResult.payurl,
qrCode: easyPayResult.qrcode,
expiresAt,
};
} catch (error) {
await prisma.order.delete({ where: { id: order.id } });
throw error;
}
}
export async function cancelOrder(orderId: string, userId: number): Promise<void> {
const result = await prisma.order.updateMany({
where: { id: orderId, userId, status: 'PENDING' },
data: { status: 'CANCELLED', updatedAt: new Date() },
});
if (result.count === 0) {
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
if (order.userId !== userId) throw new OrderError('FORBIDDEN', 'Forbidden', 403);
throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
}
await prisma.auditLog.create({
data: {
orderId,
action: 'ORDER_CANCELLED',
detail: 'User cancelled order',
operator: `user:${userId}`,
},
});
}
export async function adminCancelOrder(orderId: string): Promise<void> {
const result = await prisma.order.updateMany({
where: { id: orderId, status: 'PENDING' },
data: { status: 'CANCELLED', updatedAt: new Date() },
});
if (result.count === 0) {
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
}
await prisma.auditLog.create({
data: {
orderId,
action: 'ORDER_CANCELLED',
detail: 'Admin cancelled order',
operator: 'admin',
},
});
}
export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<boolean> {
const env = getEnv();
const { sign, ...rest } = params;
const paramsForSign: Record<string, string> = {};
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined && value !== null) {
paramsForSign[key] = String(value);
}
}
if (!verifySign(paramsForSign, env.EASY_PAY_PKEY, sign)) {
console.error('EasyPay notify: invalid signature');
return false;
}
if (params.trade_status !== 'TRADE_SUCCESS') {
return true;
}
const order = await prisma.order.findUnique({
where: { id: params.out_trade_no },
});
if (!order) {
console.error('EasyPay notify: order not found:', params.out_trade_no);
return false;
}
let paidAmount: Prisma.Decimal;
try {
paidAmount = new Prisma.Decimal(params.money);
} catch {
console.error('EasyPay notify: invalid money format:', params.money);
return false;
}
if (paidAmount.lte(0)) {
console.error('EasyPay notify: non-positive money:', params.money);
return false;
}
if (!paidAmount.equals(order.amount)) {
console.warn('EasyPay notify: amount changed, use paid amount', order.amount.toString(), params.money);
}
const result = await prisma.order.updateMany({
where: {
id: order.id,
status: { in: ['PENDING', 'EXPIRED'] },
},
data: {
status: 'PAID',
amount: paidAmount,
zpayTradeNo: params.trade_no,
paidAt: new Date(),
failedAt: null,
failedReason: null,
},
});
if (result.count === 0) {
return true;
}
await prisma.auditLog.create({
data: {
orderId: order.id,
action: 'ORDER_PAID',
detail: JSON.stringify({
previous_status: order.status,
trade_no: params.trade_no,
expected_amount: order.amount.toString(),
paid_amount: paidAmount.toString(),
}),
operator: 'easy-pay',
},
});
try {
// Recharge inline to avoid "paid but still recharging" async gaps.
await executeRecharge(order.id);
} catch (err) {
// Payment has been confirmed, always ack notify to avoid endless retries from gateway.
console.error('Recharge failed for order:', order.id, err);
}
return true;
}
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);
}
if (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') {
throw new OrderError('INVALID_STATUS', `Order cannot recharge in status ${order.status}`, 400);
}
try {
await createAndRedeem(
order.rechargeCode,
Number(order.amount),
order.userId,
`sub2apipay recharge order:${orderId}`,
);
await prisma.order.update({
where: { id: orderId },
data: { status: 'COMPLETED', completedAt: new Date() },
});
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: {
status: 'FAILED',
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;
}
}
function assertRetryAllowed(order: { status: string; paidAt: Date | null }): void {
if (!order.paidAt) {
throw new OrderError('INVALID_STATUS', 'Order is not paid, retry denied', 400);
}
if (isRefundStatus(order.status)) {
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400);
}
if (order.status === 'FAILED' || order.status === 'PAID') {
return;
}
if (order.status === 'RECHARGING') {
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
}
if (order.status === 'COMPLETED') {
throw new OrderError('INVALID_STATUS', 'Order already completed', 400);
}
throw new OrderError('INVALID_STATUS', 'Only paid and failed orders can retry', 400);
}
export async function retryRecharge(orderId: string): Promise<void> {
const order = await prisma.order.findUnique({
where: { id: orderId },
select: {
id: true,
status: true,
paidAt: true,
completedAt: true,
},
});
if (!order) {
throw new OrderError('NOT_FOUND', 'Order not found', 404);
}
assertRetryAllowed(order);
const result = await prisma.order.updateMany({
where: {
id: orderId,
status: { in: ['FAILED', 'PAID'] },
paidAt: { not: null },
},
data: { status: 'PAID', failedAt: null, failedReason: null },
});
if (result.count === 0) {
const latest = await prisma.order.findUnique({
where: { id: orderId },
select: {
status: true,
paidAt: true,
completedAt: true,
},
});
if (!latest) {
throw new OrderError('NOT_FOUND', 'Order not found', 404);
}
const derived = deriveOrderState(latest);
if (derived.rechargeStatus === 'recharging' || latest.status === 'PAID') {
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
}
if (derived.rechargeStatus === 'success') {
throw new OrderError('INVALID_STATUS', 'Order already completed', 400);
}
if (isRefundStatus(latest.status)) {
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400);
}
throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409);
}
await prisma.auditLog.create({
data: {
orderId,
action: 'RECHARGE_RETRY',
detail: 'Admin manual retry recharge',
operator: 'admin',
},
});
await executeRecharge(orderId);
}
export interface RefundInput {
orderId: string;
reason?: string;
force?: boolean;
}
export interface RefundResult {
success: boolean;
warning?: string;
requireForce?: boolean;
}
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') {
throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400);
}
const amount = Number(order.amount);
if (!input.force) {
try {
const user = await getUser(order.userId);
if (user.balance < amount) {
return {
success: false,
warning: `User balance ${user.balance} is lower than refund ${amount}`,
requireForce: true,
};
}
} catch {
return {
success: false,
warning: 'Cannot fetch user balance, use force=true',
requireForce: true,
};
}
}
const lockResult = await prisma.order.updateMany({
where: { id: input.orderId, status: 'COMPLETED' },
data: { status: 'REFUNDING' },
});
if (lockResult.count === 0) {
throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409);
}
try {
if (order.zpayTradeNo) {
await easyPayRefund(order.zpayTradeNo, order.id, amount.toFixed(2));
}
await subtractBalance(
order.userId,
amount,
`sub2apipay refund order:${order.id}`,
`sub2apipay:refund:${order.id}`,
);
await prisma.order.update({
where: { id: input.orderId },
data: {
status: 'REFUNDED',
refundAmount: new Prisma.Decimal(amount.toFixed(2)),
refundReason: input.reason || null,
refundAt: new Date(),
forceRefund: input.force || false,
},
});
await prisma.auditLog.create({
data: {
orderId: input.orderId,
action: 'REFUND_SUCCESS',
detail: JSON.stringify({ amount, reason: input.reason, force: input.force }),
operator: 'admin',
},
});
return { success: true };
} catch (error) {
await prisma.order.update({
where: { id: input.orderId },
data: {
status: 'REFUND_FAILED',
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;
}
}

66
src/lib/order/status.ts Normal file
View File

@@ -0,0 +1,66 @@
export type RechargeStatus =
| 'not_paid'
| 'paid_pending'
| 'recharging'
| 'success'
| 'failed'
| 'closed';
export interface OrderStatusLike {
status: string;
paidAt?: Date | string | null;
completedAt?: Date | string | null;
}
const CLOSED_STATUSES = new Set([
'EXPIRED',
'CANCELLED',
'REFUNDING',
'REFUNDED',
'REFUND_FAILED',
]);
const REFUND_STATUSES = new Set(['REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
function hasDate(value: Date | string | null | undefined): boolean {
return Boolean(value);
}
export function isRefundStatus(status: string): boolean {
return REFUND_STATUSES.has(status);
}
export function isRechargeRetryable(order: OrderStatusLike): boolean {
return hasDate(order.paidAt) && order.status === 'FAILED' && !isRefundStatus(order.status);
}
export function deriveOrderState(order: OrderStatusLike): {
paymentSuccess: boolean;
rechargeSuccess: boolean;
rechargeStatus: RechargeStatus;
} {
const paymentSuccess = hasDate(order.paidAt);
const rechargeSuccess = hasDate(order.completedAt) || order.status === 'COMPLETED';
if (rechargeSuccess) {
return { paymentSuccess, rechargeSuccess: true, rechargeStatus: 'success' };
}
if (order.status === 'RECHARGING') {
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'recharging' };
}
if (order.status === 'FAILED') {
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'failed' };
}
if (CLOSED_STATUSES.has(order.status)) {
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'closed' };
}
if (paymentSuccess) {
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'paid_pending' };
}
return { paymentSuccess: false, rechargeSuccess: false, rechargeStatus: 'not_paid' };
}

42
src/lib/order/timeout.ts Normal file
View File

@@ -0,0 +1,42 @@
import { prisma } from '@/lib/db';
const INTERVAL_MS = 30_000; // 30 seconds
let timer: ReturnType<typeof setInterval> | null = null;
export async function expireOrders(): Promise<number> {
const result = await prisma.order.updateMany({
where: {
status: 'PENDING',
expiresAt: { lt: new Date() },
},
data: { status: 'EXPIRED' },
});
if (result.count > 0) {
console.log(`Expired ${result.count} orders`);
}
return result.count;
}
export function startTimeoutScheduler(): void {
if (timer) return;
// Run immediately on startup
expireOrders().catch(console.error);
// Then run every 30 seconds
timer = setInterval(() => {
expireOrders().catch(console.error);
}, INTERVAL_MS);
console.log('Order timeout scheduler started');
}
export function stopTimeoutScheduler(): void {
if (timer) {
clearInterval(timer);
timer = null;
console.log('Order timeout scheduler stopped');
}
}

102
src/lib/sub2api/client.ts Normal file
View File

@@ -0,0 +1,102 @@
import { getEnv } from '@/lib/config';
import type { Sub2ApiUser, Sub2ApiRedeemCode } from './types';
function getHeaders(idempotencyKey?: string): Record<string, string> {
const env = getEnv();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-api-key': env.SUB2API_ADMIN_API_KEY,
};
if (idempotencyKey) {
headers['Idempotency-Key'] = idempotencyKey;
}
return headers;
}
export async function getCurrentUserByToken(token: string): Promise<Sub2ApiUser> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/auth/me`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to get current user: ${response.status}`);
}
const data = await response.json();
return data.data as Sub2ApiUser;
}
export async function getUser(userId: number): Promise<Sub2ApiUser> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}`, {
headers: getHeaders(),
});
if (!response.ok) {
if (response.status === 404) throw new Error('USER_NOT_FOUND');
throw new Error(`Failed to get user: ${response.status}`);
}
const data = await response.json();
return data.data as Sub2ApiUser;
}
export async function createAndRedeem(
code: string,
value: number,
userId: number,
notes: string,
): Promise<Sub2ApiRedeemCode> {
const env = getEnv();
const response = await fetch(
`${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`,
{
method: 'POST',
headers: getHeaders(`sub2apipay:recharge:${code}`),
body: JSON.stringify({
code,
type: 'balance',
value,
user_id: userId,
notes,
}),
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Recharge failed (${response.status}): ${JSON.stringify(errorData)}`);
}
const data = await response.json();
return data.redeem_code as Sub2ApiRedeemCode;
}
export async function subtractBalance(
userId: number,
amount: number,
notes: string,
idempotencyKey: string,
): Promise<void> {
const env = getEnv();
const response = await fetch(
`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`,
{
method: 'POST',
headers: getHeaders(idempotencyKey),
body: JSON.stringify({
operation: 'subtract',
amount,
notes,
}),
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Subtract balance failed (${response.status}): ${JSON.stringify(errorData)}`);
}
}

23
src/lib/sub2api/types.ts Normal file
View File

@@ -0,0 +1,23 @@
export interface Sub2ApiUser {
id: number;
username: string;
email: string;
status: string; // "active", "banned", etc.
balance: number;
}
export interface Sub2ApiRedeemCode {
id: number;
code: string;
type: string;
value: number;
status: string;
used_by: number;
used_at: string;
}
export interface Sub2ApiResponse<T> {
code: number;
data?: T;
message?: string;
}

74
src/lib/zpay/client.ts Normal file
View File

@@ -0,0 +1,74 @@
import { getEnv } from '@/lib/config';
import { generateSign } from './sign';
import type { ZPayCreateResponse, ZPayQueryResponse, ZPayRefundResponse } from './types';
export interface CreatePaymentOptions {
outTradeNo: string;
amount: string; // 金额字符串,如 "10.00"
paymentType: 'alipay' | 'wxpay';
clientIp: string;
productName: string;
}
export async function createPayment(opts: CreatePaymentOptions): Promise<ZPayCreateResponse> {
const env = getEnv();
const params: Record<string, string> = {
pid: env.ZPAY_PID,
type: opts.paymentType,
out_trade_no: opts.outTradeNo,
notify_url: env.ZPAY_NOTIFY_URL,
return_url: env.ZPAY_RETURN_URL,
name: opts.productName,
money: opts.amount,
clientip: opts.clientIp,
};
const sign = generateSign(params, env.ZPAY_PKEY);
params.sign = sign;
params.sign_type = 'MD5';
const formData = new URLSearchParams(params);
const response = await fetch(`${env.ZPAY_API_BASE}/mapi.php`, {
method: 'POST',
body: formData,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const data = await response.json() as ZPayCreateResponse;
if (data.code !== 1) {
throw new Error(`ZPAY create payment failed: ${data.msg || 'unknown error'}`);
}
return data;
}
export async function queryOrder(outTradeNo: string): Promise<ZPayQueryResponse> {
const env = getEnv();
const url = `${env.ZPAY_API_BASE}/api.php?act=order&pid=${env.ZPAY_PID}&key=${env.ZPAY_PKEY}&out_trade_no=${outTradeNo}`;
const response = await fetch(url);
const data = await response.json() as ZPayQueryResponse;
if (data.code !== 1) {
throw new Error(`ZPAY query order failed: ${data.msg || 'unknown error'}`);
}
return data;
}
export async function refund(tradeNo: string, outTradeNo: string, money: string): Promise<ZPayRefundResponse> {
const env = getEnv();
const params = new URLSearchParams({
pid: env.ZPAY_PID,
key: env.ZPAY_PKEY,
trade_no: tradeNo,
out_trade_no: outTradeNo,
money,
});
const response = await fetch(`${env.ZPAY_API_BASE}/api.php?act=refund`, {
method: 'POST',
body: params,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const data = await response.json() as ZPayRefundResponse;
if (data.code !== 1) {
throw new Error(`ZPAY refund failed: ${data.msg || 'unknown error'}`);
}
return data;
}

19
src/lib/zpay/sign.ts Normal file
View File

@@ -0,0 +1,19 @@
import crypto from 'crypto';
export function generateSign(params: Record<string, string>, pkey: string): string {
const filtered = Object.entries(params)
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
.sort(([a], [b]) => a.localeCompare(b));
const queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&');
const signStr = queryString + pkey;
return crypto.createHash('md5').update(signStr).digest('hex');
}
export function verifySign(params: Record<string, string>, pkey: string, sign: string): boolean {
const expected = generateSign(params, pkey);
if (expected.length !== sign.length) return false;
const a = Buffer.from(expected);
const b = Buffer.from(sign);
return crypto.timingSafeEqual(a, b);
}

56
src/lib/zpay/types.ts Normal file
View File

@@ -0,0 +1,56 @@
export interface ZPayCreateParams {
pid: string;
type: 'alipay' | 'wxpay';
out_trade_no: string;
notify_url: string;
name: string;
money: string;
clientip: string;
return_url: string;
sign?: string;
sign_type?: string;
}
export interface ZPayCreateResponse {
code: number;
msg?: string;
trade_no: string;
O_id?: string;
payurl?: string;
qrcode?: string;
img?: string;
}
export interface ZPayNotifyParams {
pid: string;
name: string;
money: string;
out_trade_no: string;
trade_no: string;
param?: string;
trade_status: string;
type: string;
sign: string;
sign_type: string;
}
export interface ZPayQueryResponse {
code: number;
msg?: string;
trade_no: string;
out_trade_no: string;
type: string;
pid: string;
addtime: string;
endtime: string;
name: string;
money: string;
status: number;
param?: string;
buyer?: string;
}
export interface ZPayRefundResponse {
code: number;
msg: string;
}