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:
19
src/lib/admin-auth.ts
Normal file
19
src/lib/admin-auth.ts
Normal 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
139
src/lib/config.ts
Normal 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
14
src/lib/db.ts
Normal 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;
|
||||
97
src/lib/easy-pay/client.ts
Normal file
97
src/lib/easy-pay/client.ts
Normal 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
19
src/lib/easy-pay/sign.ts
Normal 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
57
src/lib/easy-pay/types.ts
Normal 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;
|
||||
}
|
||||
6
src/lib/order/code-gen.ts
Normal file
6
src/lib/order/code-gen.ts
Normal 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
509
src/lib/order/service.ts
Normal 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
66
src/lib/order/status.ts
Normal 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
42
src/lib/order/timeout.ts
Normal 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
102
src/lib/sub2api/client.ts
Normal 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
23
src/lib/sub2api/types.ts
Normal 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
74
src/lib/zpay/client.ts
Normal 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
19
src/lib/zpay/sign.ts
Normal 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
56
src/lib/zpay/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user