fix: harden alipay direct pay flow
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { generateSign } from './sign';
|
||||
import type { AlipayResponse } from './types';
|
||||
import { parseAlipayJsonResponse } from './codec';
|
||||
|
||||
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
|
||||
|
||||
@@ -32,7 +33,7 @@ function assertAlipayEnv(env: ReturnType<typeof getEnv>) {
|
||||
*/
|
||||
export function pageExecute(
|
||||
bizContent: Record<string, unknown>,
|
||||
options?: { notifyUrl?: string; returnUrl?: string; method?: string },
|
||||
options?: { notifyUrl?: string; returnUrl?: string | null; method?: string },
|
||||
): string {
|
||||
const env = assertAlipayEnv(getEnv());
|
||||
|
||||
@@ -45,7 +46,7 @@ export function pageExecute(
|
||||
if (options?.notifyUrl || env.ALIPAY_NOTIFY_URL) {
|
||||
params.notify_url = (options?.notifyUrl || env.ALIPAY_NOTIFY_URL)!;
|
||||
}
|
||||
if (options?.returnUrl || env.ALIPAY_RETURN_URL) {
|
||||
if (options?.returnUrl !== null && (options?.returnUrl || env.ALIPAY_RETURN_URL)) {
|
||||
params.return_url = (options?.returnUrl || env.ALIPAY_RETURN_URL)!;
|
||||
}
|
||||
|
||||
@@ -62,6 +63,7 @@ export function pageExecute(
|
||||
export async function execute<T extends AlipayResponse>(
|
||||
method: string,
|
||||
bizContent: Record<string, unknown>,
|
||||
options?: { notifyUrl?: string; returnUrl?: string },
|
||||
): Promise<T> {
|
||||
const env = assertAlipayEnv(getEnv());
|
||||
|
||||
@@ -71,6 +73,13 @@ export async function execute<T extends AlipayResponse>(
|
||||
biz_content: JSON.stringify(bizContent),
|
||||
};
|
||||
|
||||
if (options?.notifyUrl) {
|
||||
params.notify_url = options.notifyUrl;
|
||||
}
|
||||
if (options?.returnUrl) {
|
||||
params.return_url = options.returnUrl;
|
||||
}
|
||||
|
||||
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
|
||||
|
||||
const response = await fetch(GATEWAY, {
|
||||
@@ -80,11 +89,11 @@ export async function execute<T extends AlipayResponse>(
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const data = await parseAlipayJsonResponse<Record<string, unknown>>(response);
|
||||
|
||||
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
|
||||
const responseKey = method.replace(/\./g, '_') + '_response';
|
||||
const result = data[responseKey] as T;
|
||||
const result = data[responseKey] as T | undefined;
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`Alipay API error: unexpected response format for ${method}`);
|
||||
|
||||
103
src/lib/alipay/codec.ts
Normal file
103
src/lib/alipay/codec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
const HEADER_CHARSET_RE = /charset=([^;]+)/i;
|
||||
const BODY_CHARSET_RE = /(?:^|&)charset=([^&]+)/i;
|
||||
|
||||
function normalizeCharset(charset: string | null | undefined): string | null {
|
||||
if (!charset) return null;
|
||||
|
||||
const normalized = charset.trim().replace(/^['"]|['"]$/g, '').toLowerCase();
|
||||
if (!normalized) return null;
|
||||
|
||||
switch (normalized) {
|
||||
case 'utf8':
|
||||
return 'utf-8';
|
||||
case 'gb2312':
|
||||
case 'gb_2312-80':
|
||||
return 'gbk';
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function detectCharsetFromHeaders(headers: Record<string, string>): string | null {
|
||||
const contentType = headers['content-type'];
|
||||
const match = contentType?.match(HEADER_CHARSET_RE);
|
||||
return normalizeCharset(match?.[1]);
|
||||
}
|
||||
|
||||
function detectCharsetFromBody(rawBody: Buffer): string | null {
|
||||
const latin1Body = rawBody.toString('latin1');
|
||||
const match = latin1Body.match(BODY_CHARSET_RE);
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
return normalizeCharset(decodeURIComponent(match[1].replace(/\+/g, ' ')));
|
||||
} catch {
|
||||
return normalizeCharset(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBuffer(rawBody: Buffer, charset: string): string {
|
||||
return new TextDecoder(charset).decode(rawBody);
|
||||
}
|
||||
|
||||
export function decodeAlipayPayload(rawBody: string | Buffer, headers: Record<string, string> = {}): string {
|
||||
if (typeof rawBody === 'string') {
|
||||
return rawBody;
|
||||
}
|
||||
|
||||
const primaryCharset = detectCharsetFromHeaders(headers) || detectCharsetFromBody(rawBody) || 'utf-8';
|
||||
const candidates = Array.from(new Set([primaryCharset, 'utf-8', 'gbk', 'gb18030']));
|
||||
|
||||
let fallbackDecoded: string | null = null;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const charset of candidates) {
|
||||
try {
|
||||
const decoded = decodeBuffer(rawBody, charset);
|
||||
if (!decoded.includes('\uFFFD')) {
|
||||
return decoded;
|
||||
}
|
||||
fallbackDecoded ??= decoded;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackDecoded) {
|
||||
return fallbackDecoded;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to decode Alipay payload${lastError instanceof Error ? `: ${lastError.message}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeAlipaySignature(sign: string): string {
|
||||
return sign.replace(/ /g, '+').trim();
|
||||
}
|
||||
|
||||
export function parseAlipayNotificationParams(
|
||||
rawBody: string | Buffer,
|
||||
headers: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
const body = decodeAlipayPayload(rawBody, headers);
|
||||
const searchParams = new URLSearchParams(body);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
if (params.sign) {
|
||||
params.sign = normalizeAlipaySignature(params.sign);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function parseAlipayJsonResponse<T>(response: Response): Promise<T> {
|
||||
const rawBody = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const text = decodeAlipayPayload(rawBody, { 'content-type': contentType });
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
@@ -11,7 +11,58 @@ import type {
|
||||
import { pageExecute, execute } from './client';
|
||||
import { verifySign } from './sign';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { AlipayTradeQueryResponse, AlipayTradeRefundResponse, AlipayTradeCloseResponse } from './types';
|
||||
import type {
|
||||
AlipayTradeQueryResponse,
|
||||
AlipayTradeRefundResponse,
|
||||
AlipayTradeCloseResponse,
|
||||
} from './types';
|
||||
import { parseAlipayNotificationParams } from './codec';
|
||||
|
||||
export interface BuildAlipayPaymentUrlInput {
|
||||
orderId: string;
|
||||
amount: number;
|
||||
subject: string;
|
||||
notifyUrl?: string;
|
||||
returnUrl?: string | null;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
function isTradeNotExistError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.message.includes('[ACQ.TRADE_NOT_EXIST]');
|
||||
}
|
||||
|
||||
function getRequiredParam(params: Record<string, string>, key: string): string {
|
||||
const value = params[key]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Alipay notification missing required field: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildAlipayPaymentUrl(input: BuildAlipayPaymentUrlInput): string {
|
||||
const method = input.isMobile ? 'alipay.trade.wap.pay' : 'alipay.trade.page.pay';
|
||||
const productCode = input.isMobile ? 'QUICK_WAP_WAY' : 'FAST_INSTANT_TRADE_PAY';
|
||||
|
||||
return pageExecute(
|
||||
{
|
||||
out_trade_no: input.orderId,
|
||||
product_code: productCode,
|
||||
total_amount: input.amount.toFixed(2),
|
||||
subject: input.subject,
|
||||
},
|
||||
{
|
||||
notifyUrl: input.notifyUrl,
|
||||
returnUrl: input.returnUrl,
|
||||
method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAlipayEntryUrl(orderId: string): string {
|
||||
const env = getEnv();
|
||||
return new URL(`/pay/${orderId}`, env.NEXT_PUBLIC_APP_URL).toString();
|
||||
}
|
||||
|
||||
export class AlipayProvider implements PaymentProvider {
|
||||
readonly name = 'alipay-direct';
|
||||
@@ -22,42 +73,43 @@ export class AlipayProvider implements PaymentProvider {
|
||||
};
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const buildPayUrl = (mobile: boolean) => {
|
||||
const method = mobile ? 'alipay.trade.wap.pay' : 'alipay.trade.page.pay';
|
||||
const productCode = mobile ? 'QUICK_WAP_WAY' : 'FAST_INSTANT_TRADE_PAY';
|
||||
return pageExecute(
|
||||
{
|
||||
out_trade_no: request.orderId,
|
||||
product_code: productCode,
|
||||
total_amount: request.amount.toFixed(2),
|
||||
subject: request.subject,
|
||||
},
|
||||
{
|
||||
notifyUrl: request.notifyUrl,
|
||||
returnUrl: request.returnUrl,
|
||||
method,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
let url: string;
|
||||
if (request.isMobile) {
|
||||
try {
|
||||
url = buildPayUrl(true);
|
||||
} catch {
|
||||
url = buildPayUrl(false);
|
||||
}
|
||||
} else {
|
||||
url = buildPayUrl(false);
|
||||
if (!request.isMobile) {
|
||||
const entryUrl = buildAlipayEntryUrl(request.orderId);
|
||||
return {
|
||||
tradeNo: request.orderId,
|
||||
payUrl: entryUrl,
|
||||
qrCode: entryUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return { tradeNo: request.orderId, payUrl: url };
|
||||
const payUrl = buildAlipayPaymentUrl({
|
||||
orderId: request.orderId,
|
||||
amount: request.amount,
|
||||
subject: request.subject,
|
||||
notifyUrl: request.notifyUrl,
|
||||
returnUrl: request.returnUrl,
|
||||
isMobile: true,
|
||||
});
|
||||
|
||||
return { tradeNo: request.orderId, payUrl };
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
const result = await execute<AlipayTradeQueryResponse>('alipay.trade.query', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
let result: AlipayTradeQueryResponse;
|
||||
try {
|
||||
result = await execute<AlipayTradeQueryResponse>('alipay.trade.query', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTradeNotExistError(error)) {
|
||||
return {
|
||||
tradeNo,
|
||||
status: 'pending',
|
||||
amount: 0,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let status: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||
switch (result.trade_status) {
|
||||
@@ -80,37 +132,41 @@ export class AlipayProvider implements PaymentProvider {
|
||||
};
|
||||
}
|
||||
|
||||
async verifyNotification(rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
|
||||
async verifyNotification(rawBody: string | Buffer, headers: Record<string, string>): Promise<PaymentNotification> {
|
||||
const env = getEnv();
|
||||
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
|
||||
const searchParams = new URLSearchParams(body);
|
||||
const params = parseAlipayNotificationParams(rawBody, headers);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
// sign_type 过滤:仅接受 RSA2
|
||||
if (params.sign_type && params.sign_type !== 'RSA2') {
|
||||
if (params.sign_type && params.sign_type.toUpperCase() !== 'RSA2') {
|
||||
throw new Error('Unsupported sign_type, only RSA2 is accepted');
|
||||
}
|
||||
|
||||
const sign = params.sign || '';
|
||||
const sign = getRequiredParam(params, 'sign');
|
||||
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(params, env.ALIPAY_PUBLIC_KEY, sign)) {
|
||||
throw new Error('Alipay notification signature verification failed');
|
||||
throw new Error(
|
||||
'Alipay notification signature verification failed (check ALIPAY_PUBLIC_KEY uses Alipay public key, not app public key, and rebuild/redeploy the latest image)',
|
||||
);
|
||||
}
|
||||
|
||||
// app_id 校验
|
||||
if (params.app_id !== env.ALIPAY_APP_ID) {
|
||||
const tradeNo = getRequiredParam(params, 'trade_no');
|
||||
const orderId = getRequiredParam(params, 'out_trade_no');
|
||||
const tradeStatus = getRequiredParam(params, 'trade_status');
|
||||
const appId = getRequiredParam(params, 'app_id');
|
||||
|
||||
if (appId !== env.ALIPAY_APP_ID) {
|
||||
throw new Error('Alipay notification app_id mismatch');
|
||||
}
|
||||
|
||||
const amount = Number.parseFloat(getRequiredParam(params, 'total_amount'));
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error('Alipay notification invalid total_amount');
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: params.trade_no || '',
|
||||
orderId: params.out_trade_no || '',
|
||||
amount: Math.round(parseFloat(params.total_amount || '0') * 100) / 100,
|
||||
tradeNo,
|
||||
orderId,
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
status:
|
||||
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||
tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||
rawData: params,
|
||||
};
|
||||
}
|
||||
@@ -130,8 +186,15 @@ export class AlipayProvider implements PaymentProvider {
|
||||
}
|
||||
|
||||
async cancelPayment(tradeNo: string): Promise<void> {
|
||||
await execute<AlipayTradeCloseResponse>('alipay.trade.close', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
try {
|
||||
await execute<AlipayTradeCloseResponse>('alipay.trade.close', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTradeNotExistError(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/** 将裸 base64 按 64 字符/行折行,符合 PEM 标准(OpenSSL 3.x 严格模式要求) */
|
||||
function wrapBase64(b64: string): string {
|
||||
return b64.replace(/(.{64})/g, '$1\n').trim();
|
||||
}
|
||||
|
||||
function normalizePemLikeValue(key: string): string {
|
||||
return key.trim().replace(/\r\n/g, '\n').replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
function shouldLogVerifyDebug(): boolean {
|
||||
return process.env.NODE_ENV !== 'production' || process.env.DEBUG_ALIPAY_SIGN === '1';
|
||||
}
|
||||
|
||||
/** 自动补全 PEM 格式(PKCS8) */
|
||||
function formatPrivateKey(key: string): string {
|
||||
if (key.includes('-----BEGIN')) return key;
|
||||
return `-----BEGIN PRIVATE KEY-----\n${key}\n-----END PRIVATE KEY-----`;
|
||||
const normalized = normalizePemLikeValue(key);
|
||||
if (normalized.includes('-----BEGIN')) return normalized;
|
||||
return `-----BEGIN PRIVATE KEY-----\n${wrapBase64(normalized)}\n-----END PRIVATE KEY-----`;
|
||||
}
|
||||
|
||||
function formatPublicKey(key: string): string {
|
||||
if (key.includes('-----BEGIN')) return key;
|
||||
return `-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC KEY-----`;
|
||||
const normalized = normalizePemLikeValue(key);
|
||||
if (normalized.includes('-----BEGIN')) return normalized;
|
||||
return `-----BEGIN PUBLIC KEY-----\n${wrapBase64(normalized)}\n-----END PUBLIC KEY-----`;
|
||||
}
|
||||
|
||||
/** 生成 RSA2 签名 */
|
||||
/** 生成 RSA2 签名(请求签名:仅排除 sign) */
|
||||
export function generateSign(params: Record<string, string>, privateKey: string): string {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
|
||||
.filter(([key, value]) => key !== 'sign' && value !== '' && value !== undefined && value !== null)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
@@ -24,7 +39,7 @@ export function generateSign(params: Record<string, string>, privateKey: string)
|
||||
return signer.sign(formatPrivateKey(privateKey), 'base64');
|
||||
}
|
||||
|
||||
/** 用支付宝公钥验证签名 */
|
||||
/** 用支付宝公钥验证签名(回调验签:排除 sign 和 sign_type) */
|
||||
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
|
||||
@@ -32,7 +47,28 @@ export function verifySign(params: Record<string, string>, alipayPublicKey: stri
|
||||
|
||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(signStr);
|
||||
return verifier.verify(formatPublicKey(alipayPublicKey), sign, 'base64');
|
||||
const pem = formatPublicKey(alipayPublicKey);
|
||||
try {
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(signStr);
|
||||
const result = verifier.verify(pem, sign, 'base64');
|
||||
if (!result) {
|
||||
if (shouldLogVerifyDebug()) {
|
||||
console.error('[Alipay verifySign] FAILED. signStr:', signStr.substring(0, 200) + '...');
|
||||
console.error('[Alipay verifySign] sign(first 40):', sign.substring(0, 40));
|
||||
console.error('[Alipay verifySign] pubKey(first 80):', pem.substring(0, 80));
|
||||
} else {
|
||||
console.error('[Alipay verifySign] verification failed');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (shouldLogVerifyDebug()) {
|
||||
console.error('[Alipay verifySign] crypto error:', err);
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('[Alipay verifySign] crypto error:', message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getEnv } from '@/lib/config';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { getMethodFeeRate } from './fee';
|
||||
import { getBizDayStartUTC } from '@/lib/time/biz-day';
|
||||
|
||||
/**
|
||||
* 获取指定支付渠道的每日全平台限额(0 = 不限制)。
|
||||
@@ -12,20 +13,18 @@ export function getMethodDailyLimit(paymentType: string): number {
|
||||
const env = getEnv();
|
||||
const key = `MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}` as keyof typeof env;
|
||||
const val = env[key];
|
||||
if (typeof val === 'number') return val; // 明确配置(含 0)
|
||||
if (typeof val === 'number') return val;
|
||||
|
||||
// 尝试从已注册的 provider 取默认值
|
||||
initPaymentProviders();
|
||||
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
|
||||
if (providerDefault?.dailyMax !== undefined) return providerDefault.dailyMax;
|
||||
|
||||
// 兜底:process.env(支持未在 schema 中声明的动态渠道)
|
||||
const raw = process.env[`MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}`];
|
||||
if (raw !== undefined) {
|
||||
const num = Number(raw);
|
||||
return Number.isFinite(num) && num >= 0 ? num : 0;
|
||||
}
|
||||
return 0; // 默认不限制
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,21 +42,15 @@ export function getMethodSingleLimit(paymentType: string): number {
|
||||
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
|
||||
if (providerDefault?.singleMax !== undefined) return providerDefault.singleMax;
|
||||
|
||||
return 0; // 使用全局 MAX_RECHARGE_AMOUNT
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface MethodLimitStatus {
|
||||
/** 每日限额,0 = 不限 */
|
||||
dailyLimit: number;
|
||||
/** 今日已使用金额 */
|
||||
used: number;
|
||||
/** 剩余每日额度,null = 不限 */
|
||||
remaining: number | null;
|
||||
/** 是否还可使用(false = 今日额度已满) */
|
||||
available: boolean;
|
||||
/** 单笔限额,0 = 使用全局配置 MAX_RECHARGE_AMOUNT */
|
||||
singleMax: number;
|
||||
/** 手续费率百分比,0 = 无手续费 */
|
||||
feeRate: number;
|
||||
}
|
||||
|
||||
@@ -66,8 +59,7 @@ export interface MethodLimitStatus {
|
||||
* 一次 DB groupBy 完成,调用方按需传入渠道列表。
|
||||
*/
|
||||
export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<string, MethodLimitStatus>> {
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const todayStart = getBizDayStartUTC();
|
||||
|
||||
const usageRows = await prisma.order.groupBy({
|
||||
by: ['paymentType'],
|
||||
@@ -79,7 +71,7 @@ export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<
|
||||
_sum: { amount: true },
|
||||
});
|
||||
|
||||
const usageMap = Object.fromEntries(usageRows.map((r) => [r.paymentType, Number(r._sum.amount ?? 0)]));
|
||||
const usageMap = Object.fromEntries(usageRows.map((row) => [row.paymentType, Number(row._sum.amount ?? 0)]));
|
||||
|
||||
const result: Record<string, MethodLimitStatus> = {};
|
||||
for (const type of paymentTypes) {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { getUser, createAndRedeem, subtractBalance, addBalance } from '@/lib/sub
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { deriveOrderState, isRefundStatus } from './status';
|
||||
import { pickLocaleText, type Locale } from '@/lib/locale';
|
||||
import { getBizDayStartUTC } from '@/lib/time/biz-day';
|
||||
import { buildOrderResultUrl, createOrderStatusAccessToken } from '@/lib/order/status-access';
|
||||
|
||||
const MAX_PENDING_ORDERS = 3;
|
||||
|
||||
@@ -41,11 +43,13 @@ export interface CreateOrderResult {
|
||||
qrCode?: string | null;
|
||||
clientSecret?: string | null;
|
||||
expiresAt: Date;
|
||||
statusAccessToken: string;
|
||||
}
|
||||
|
||||
export async function createOrder(input: CreateOrderInput): Promise<CreateOrderResult> {
|
||||
const env = getEnv();
|
||||
const locale = input.locale ?? 'zh';
|
||||
const todayStart = getBizDayStartUTC();
|
||||
|
||||
const user = await getUser(input.userId);
|
||||
if (user.status !== 'active') {
|
||||
@@ -65,8 +69,6 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
|
||||
// 每日累计充值限额校验(0 = 不限制)
|
||||
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const dailyAgg = await prisma.order.aggregate({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
@@ -93,8 +95,6 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
// 渠道每日全平台限额校验(0 = 不限)
|
||||
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
|
||||
if (methodDailyLimit > 0) {
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const methodAgg = await prisma.order.aggregate({
|
||||
where: {
|
||||
paymentType: input.paymentType,
|
||||
@@ -161,12 +161,15 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider(input.paymentType);
|
||||
|
||||
// 只有 easypay 从外部传入 notifyUrl/returnUrl,其他 provider 内部读取自己的环境变量
|
||||
const statusAccessToken = createOrderStatusAccessToken(order.id);
|
||||
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id);
|
||||
|
||||
// 只有 easypay 从外部传入 notifyUrl,return_url 统一回到带访问令牌的结果页
|
||||
let notifyUrl: string | undefined;
|
||||
let returnUrl: string | undefined;
|
||||
let returnUrl: string | undefined = orderResultUrl;
|
||||
if (provider.providerKey === 'easypay') {
|
||||
notifyUrl = env.EASY_PAY_NOTIFY_URL || '';
|
||||
returnUrl = env.EASY_PAY_RETURN_URL || '';
|
||||
returnUrl = orderResultUrl;
|
||||
}
|
||||
|
||||
const paymentResult = await provider.createPayment({
|
||||
@@ -211,6 +214,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
qrCode: paymentResult.qrCode,
|
||||
clientSecret: paymentResult.clientSecret,
|
||||
expiresAt,
|
||||
statusAccessToken,
|
||||
};
|
||||
} catch (error) {
|
||||
await prisma.order.delete({ where: { id: order.id } });
|
||||
|
||||
37
src/lib/order/status-access.ts
Normal file
37
src/lib/order/status-access.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import crypto from 'crypto';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
export const ORDER_STATUS_ACCESS_QUERY_KEY = 'access_token';
|
||||
const ORDER_STATUS_ACCESS_PURPOSE = 'order-status-access:v1';
|
||||
|
||||
function buildSignature(orderId: string): string {
|
||||
return crypto
|
||||
.createHmac('sha256', getEnv().ADMIN_TOKEN)
|
||||
.update(`${ORDER_STATUS_ACCESS_PURPOSE}:${orderId}`)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
export function createOrderStatusAccessToken(orderId: string): string {
|
||||
return buildSignature(orderId);
|
||||
}
|
||||
|
||||
export function verifyOrderStatusAccessToken(orderId: string, token: string | null | undefined): boolean {
|
||||
if (!token) return false;
|
||||
|
||||
const expected = buildSignature(orderId);
|
||||
const expectedBuffer = Buffer.from(expected, 'utf8');
|
||||
const receivedBuffer = Buffer.from(token, 'utf8');
|
||||
|
||||
if (expectedBuffer.length !== receivedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
|
||||
}
|
||||
|
||||
export function buildOrderResultUrl(appUrl: string, orderId: string): string {
|
||||
const url = new URL('/pay/result', appUrl);
|
||||
url.searchParams.set('order_id', orderId);
|
||||
url.searchParams.set(ORDER_STATUS_ACCESS_QUERY_KEY, createOrderStatusAccessToken(orderId));
|
||||
return url.toString();
|
||||
}
|
||||
@@ -8,6 +8,25 @@ export interface OrderStatusLike {
|
||||
completedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
export interface DerivedOrderState {
|
||||
paymentSuccess: boolean;
|
||||
rechargeSuccess: boolean;
|
||||
rechargeStatus: RechargeStatus;
|
||||
}
|
||||
|
||||
export interface PublicOrderStatusSnapshot extends DerivedOrderState {
|
||||
id: string;
|
||||
status: string;
|
||||
expiresAt: Date | string;
|
||||
}
|
||||
|
||||
export interface OrderDisplayState {
|
||||
label: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const CLOSED_STATUSES = new Set<string>([
|
||||
ORDER_STATUS.EXPIRED,
|
||||
ORDER_STATUS.CANCELLED,
|
||||
@@ -28,11 +47,7 @@ export function isRechargeRetryable(order: OrderStatusLike): boolean {
|
||||
return hasDate(order.paidAt) && order.status === ORDER_STATUS.FAILED && !isRefundStatus(order.status);
|
||||
}
|
||||
|
||||
export function deriveOrderState(order: OrderStatusLike): {
|
||||
paymentSuccess: boolean;
|
||||
rechargeSuccess: boolean;
|
||||
rechargeStatus: RechargeStatus;
|
||||
} {
|
||||
export function deriveOrderState(order: OrderStatusLike): DerivedOrderState {
|
||||
const paymentSuccess = hasDate(order.paidAt);
|
||||
const rechargeSuccess = hasDate(order.completedAt) || order.status === ORDER_STATUS.COMPLETED;
|
||||
|
||||
@@ -58,3 +73,79 @@ export function deriveOrderState(order: OrderStatusLike): {
|
||||
|
||||
return { paymentSuccess: false, rechargeSuccess: false, rechargeStatus: 'not_paid' };
|
||||
}
|
||||
|
||||
export function getOrderDisplayState(
|
||||
order: Pick<PublicOrderStatusSnapshot, 'status' | 'paymentSuccess' | 'rechargeSuccess' | 'rechargeStatus'>,
|
||||
): OrderDisplayState {
|
||||
if (order.rechargeSuccess || order.rechargeStatus === 'success') {
|
||||
return {
|
||||
label: '充值成功',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已到账,感谢您的充值!',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.paymentSuccess) {
|
||||
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
|
||||
return {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '支付成功,正在充值余额中,请稍候...',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.rechargeStatus === 'failed') {
|
||||
return {
|
||||
label: '支付成功',
|
||||
color: 'text-amber-600',
|
||||
icon: '!',
|
||||
message: '支付已完成,但余额充值暂未完成。系统可能会自动重试,请稍后在订单列表查看;如长时间未到账请联系管理员。',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.FAILED) {
|
||||
return {
|
||||
label: '支付失败',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '支付未完成,请重新发起支付;如已扣款未到账,请联系管理员处理。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.PENDING) {
|
||||
return {
|
||||
label: '等待支付',
|
||||
color: 'text-yellow-600',
|
||||
icon: '⏳',
|
||||
message: '订单尚未完成支付。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.EXPIRED) {
|
||||
return {
|
||||
label: '订单超时',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新创建订单。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.CANCELLED) {
|
||||
return {
|
||||
label: '已取消',
|
||||
color: 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: '订单已取消。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: '支付异常',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '支付状态异常,请联系管理员处理。',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { Sub2ApiUser, Sub2ApiRedeemCode } from './types';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const RECHARGE_TIMEOUT_MS = 30_000;
|
||||
const RECHARGE_MAX_ATTEMPTS = 2;
|
||||
|
||||
function getHeaders(idempotencyKey?: string): Record<string, string> {
|
||||
const env = getEnv();
|
||||
const headers: Record<string, string> = {
|
||||
@@ -13,13 +17,18 @@ function getHeaders(idempotencyKey?: string): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
function isRetryableFetchError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.name === 'TimeoutError' || error.name === 'AbortError' || error.name === 'TypeError';
|
||||
}
|
||||
|
||||
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}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -34,7 +43,7 @@ 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(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -53,26 +62,43 @@ export async function createAndRedeem(
|
||||
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,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
const url = `${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`;
|
||||
const 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)}`);
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= RECHARGE_MAX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(`sub2apipay:recharge:${code}`),
|
||||
body,
|
||||
signal: AbortSignal.timeout(RECHARGE_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
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;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt >= RECHARGE_MAX_ATTEMPTS || !isRetryableFetchError(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(`Sub2API createAndRedeem attempt ${attempt} timed out, retrying...`);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.redeem_code as Sub2ApiRedeemCode;
|
||||
throw lastError instanceof Error ? lastError : new Error('Recharge failed');
|
||||
}
|
||||
|
||||
export async function subtractBalance(
|
||||
@@ -90,7 +116,7 @@ export async function subtractBalance(
|
||||
amount,
|
||||
notes,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -114,7 +140,7 @@ export async function addBalance(
|
||||
amount,
|
||||
notes,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
16
src/lib/time/biz-day.ts
Normal file
16
src/lib/time/biz-day.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const BIZ_TZ_NAME = 'Asia/Shanghai';
|
||||
export const BIZ_TZ_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function toBizDateStr(date: Date): string {
|
||||
const local = new Date(date.getTime() + BIZ_TZ_OFFSET_MS);
|
||||
return local.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export function getBizDayStartUTC(date: Date = new Date()): Date {
|
||||
return new Date(`${toBizDateStr(date)}T00:00:00+08:00`);
|
||||
}
|
||||
|
||||
export function getNextBizDayStartUTC(date: Date = new Date()): Date {
|
||||
return new Date(getBizDayStartUTC(date).getTime() + ONE_DAY_MS);
|
||||
}
|
||||
Reference in New Issue
Block a user