fix: harden alipay direct pay flow

This commit is contained in:
daguimu
2026-03-10 11:52:37 +08:00
parent 2492031e13
commit 8b10bc3bd5
30 changed files with 1893 additions and 437 deletions

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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 从外部传入 notifyUrlreturn_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 } });

View 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();
}

View File

@@ -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: '支付状态异常,请联系管理员处理。',
};
}

View File

@@ -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
View 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);
}