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

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;
}