Initial commit

This commit is contained in:
JetSprow
2026-04-29 05:12:39 +10:00
commit 27dbca9cbf
379 changed files with 43486 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
export interface CreatePaymentParams {
tradeNo: string;
amount: number;
subject: string;
notifyUrl: string;
returnUrl: string;
channel?: string;
}
export interface PaymentResult {
success: boolean;
paymentUrl?: string;
qrCode?: string;
tradeNo: string;
raw?: unknown;
}
export interface PaymentNotification {
tradeNo: string;
amount: number;
status: "success" | "failed";
paymentRef?: string;
raw?: unknown;
}
export interface PaymentAdapter {
readonly name: string;
createPayment(params: CreatePaymentParams): Promise<PaymentResult>;
verifyNotification(body: Record<string, string>, headers?: Record<string, string>): Promise<PaymentNotification | null>;
queryOrder(tradeNo: string, createdAfter?: number): Promise<PaymentNotification | null>;
}

View File

@@ -0,0 +1,161 @@
import crypto from "crypto";
import { fetchWithTimeout } from "@/lib/fetch-with-timeout";
import type {
PaymentAdapter,
CreatePaymentParams,
PaymentResult,
PaymentNotification,
} from "./adapter";
function wrapPem(key: string, label: string): string {
const trimmed = key.trim();
if (trimmed.startsWith("-----")) return trimmed;
const body = trimmed.replace(/\s+/g, "");
const lines = body.match(/.{1,64}/g) ?? [body];
return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----`;
}
export interface AlipayF2FConfig {
appId: string;
privateKey: string;
alipayPublicKey: string;
gateway: string;
}
export class AlipayF2FAdapter implements PaymentAdapter {
readonly name = "alipay_f2f";
private config: AlipayF2FConfig;
private pemPrivateKey: string;
private pemPublicKey: string;
constructor(config: AlipayF2FConfig) {
this.config = config;
this.pemPrivateKey = wrapPem(config.privateKey, "RSA PRIVATE KEY");
this.pemPublicKey = wrapPem(config.alipayPublicKey, "PUBLIC KEY");
}
async createPayment(params: CreatePaymentParams): Promise<PaymentResult> {
const bizContent = JSON.stringify({
out_trade_no: params.tradeNo,
total_amount: params.amount.toFixed(2),
subject: params.subject,
});
const commonParams: Record<string, string> = {
app_id: this.config.appId,
method: "alipay.trade.precreate",
charset: "utf-8",
sign_type: "RSA2",
timestamp: new Date().toISOString().replace("T", " ").slice(0, 19),
version: "1.0",
notify_url: params.notifyUrl,
biz_content: bizContent,
};
commonParams.sign = this.rsaSign(commonParams);
const res = await fetchWithTimeout(
this.config.gateway,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(commonParams).toString(),
},
15_000,
);
if (!res.ok) {
throw new Error(`支付宝当面付下单失败 (HTTP ${res.status})`);
}
const json = await res.json();
const response = json.alipay_trade_precreate_response;
if (response?.code !== "10000") {
return { success: false, tradeNo: params.tradeNo, raw: json };
}
return {
success: true,
qrCode: response.qr_code,
tradeNo: params.tradeNo,
raw: json,
};
}
async verifyNotification(
body: Record<string, string>
): Promise<PaymentNotification | null> {
const { sign, sign_type, ...rest } = body;
void sign_type;
if (!this.rsaVerify(rest, sign)) return null;
if (body.trade_status !== "TRADE_SUCCESS") return null;
return {
tradeNo: body.out_trade_no,
amount: parseFloat(body.total_amount),
status: "success",
paymentRef: body.trade_no,
};
}
async queryOrder(tradeNo: string): Promise<PaymentNotification | null> {
const bizContent = JSON.stringify({ out_trade_no: tradeNo });
const commonParams: Record<string, string> = {
app_id: this.config.appId,
method: "alipay.trade.query",
charset: "utf-8",
sign_type: "RSA2",
timestamp: new Date().toISOString().replace("T", " ").slice(0, 19),
version: "1.0",
biz_content: bizContent,
};
commonParams.sign = this.rsaSign(commonParams);
const res = await fetchWithTimeout(
this.config.gateway,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(commonParams).toString(),
},
15_000,
);
if (!res.ok) {
throw new Error(`支付宝当面付查询失败 (HTTP ${res.status})`);
}
const json = await res.json();
const response = json.alipay_trade_query_response;
if (response?.code !== "10000") return null;
if (response.trade_status !== "TRADE_SUCCESS") return null;
return {
tradeNo: response.out_trade_no,
amount: parseFloat(response.total_amount),
status: "success",
paymentRef: response.trade_no,
};
}
private rsaSign(params: Record<string, string>): string {
const sorted = Object.keys(params)
.filter((k) => params[k] !== "" && k !== "sign")
.sort();
const str = sorted.map((k) => `${k}=${params[k]}`).join("&");
const signer = crypto.createSign("RSA-SHA256");
signer.update(str);
return signer.sign(this.pemPrivateKey, "base64");
}
private rsaVerify(params: Record<string, string>, sign: string): boolean {
const sorted = Object.keys(params)
.filter((k) => params[k] !== "" && k !== "sign" && k !== "sign_type")
.sort();
const str = sorted.map((k) => `${k}=${params[k]}`).join("&");
const verifier = crypto.createVerify("RSA-SHA256");
verifier.update(str);
return verifier.verify(this.pemPublicKey, sign, "base64");
}
}

View File

@@ -0,0 +1,134 @@
import { z } from "zod";
export interface PaymentConfigField {
key: string;
label: string;
placeholder?: string;
secret?: boolean;
type?: "text" | "checkboxes";
options?: { value: string; label: string }[];
}
export interface PaymentProviderDefinition {
id: string;
name: string;
description: string;
fields: PaymentConfigField[];
}
const displayNameField = z.string().trim().optional().transform((v) => v ?? "");
const epaySchema = z.object({
displayName: displayNameField,
apiUrl: z.url("API 地址格式不正确"),
pid: z.string().trim().min(1, "商户 ID 不能为空"),
key: z.string().trim().min(1, "商户密钥不能为空"),
channels: z.string().trim().optional().transform((v) => v ?? "alipay,wxpay"),
});
const alipayF2fSchema = z.object({
displayName: displayNameField,
appId: z.string().trim().min(1, "App ID 不能为空"),
privateKey: z.string().trim().min(1, "应用私钥不能为空"),
alipayPublicKey: z.string().trim().min(1, "支付宝公钥不能为空"),
gateway: z.url("网关地址格式不正确"),
});
const usdtTrc20Schema = z.object({
displayName: displayNameField,
walletAddress: z.string().trim().min(1, "收款钱包地址不能为空"),
exchangeRate: z.coerce.number().positive("汇率必须大于 0"),
tronApiKey: z.string().trim().optional().transform((v) => v ?? ""),
tronApiUrl: z
.union([z.url("Tron API 地址格式不正确"), z.literal("")])
.optional()
.transform((value) => value ?? ""),
});
const paymentConfigSchemas = {
epay: epaySchema,
alipay_f2f: alipayF2fSchema,
usdt_trc20: usdtTrc20Schema,
} as const;
export const PAYMENT_PROVIDER_DEFINITIONS: PaymentProviderDefinition[] = [
{
id: "epay",
name: "易支付",
description: "支持支付宝/微信,通过第三方易支付平台",
fields: [
{ key: "displayName", label: "用户端显示名称", placeholder: "留空则用默认名" },
{ key: "apiUrl", label: "API 地址", placeholder: "https://pay.example.com" },
{ key: "pid", label: "商户 ID", placeholder: "1001" },
{ key: "key", label: "商户密钥", placeholder: "your-secret-key", secret: true },
{
key: "channels",
label: "启用的支付渠道",
type: "checkboxes",
options: [
{ value: "alipay", label: "支付宝" },
{ value: "wxpay", label: "微信支付" },
],
},
],
},
{
id: "alipay_f2f",
name: "支付宝当面付",
description: "支付宝官方当面付,用户扫码支付",
fields: [
{ key: "displayName", label: "用户端显示名称", placeholder: "例如:支付宝扫码" },
{ key: "appId", label: "App ID", placeholder: "2021..." },
{ key: "privateKey", label: "应用私钥", placeholder: "MIIEvQ...", secret: true },
{ key: "alipayPublicKey", label: "支付宝公钥", placeholder: "MIIBIj...", secret: true },
{ key: "gateway", label: "网关地址", placeholder: "https://openapi.alipay.com/gateway.do" },
],
},
{
id: "usdt_trc20",
name: "USDT (TRC20)",
description: "加密货币支付,监听 TRC20 链上转账",
fields: [
{ key: "displayName", label: "用户端显示名称", placeholder: "例如USDT 转账" },
{ key: "walletAddress", label: "收款钱包地址", placeholder: "T..." },
{ key: "exchangeRate", label: "汇率 (1 USDT = ¥?)", placeholder: "7.2" },
{ key: "tronApiKey", label: "TronGrid API Key", placeholder: "免费注册: trongrid.io", secret: true },
{ key: "tronApiUrl", label: "Tron API (可选)", placeholder: "https://api.trongrid.io" },
],
},
];
function normalizeConfig(config: Record<string, unknown>): Record<string, string> {
return Object.fromEntries(
Object.entries(config).map(([key, value]) => [
key,
typeof value === "string" ? value.trim() : String(value ?? ""),
]),
);
}
export function getPaymentProviderDefinition(provider: string) {
return PAYMENT_PROVIDER_DEFINITIONS.find((item) => item.id === provider) ?? null;
}
export function getPaymentProviderName(provider: string): string {
return getPaymentProviderDefinition(provider)?.name ?? provider;
}
export function normalizePaymentConfig(config: Record<string, unknown>) {
return normalizeConfig(config);
}
export function parsePaymentConfig(
provider: string,
config: Record<string, unknown>,
) {
const normalized = normalizeConfig(config);
const schema = paymentConfigSchemas[provider as keyof typeof paymentConfigSchemas];
if (!schema) {
throw new Error(`未知支付方式:${provider}`);
}
return schema.parse(normalized);
}

View File

@@ -0,0 +1,139 @@
import crypto from "crypto";
import { fetchWithTimeout } from "@/lib/fetch-with-timeout";
import type {
PaymentAdapter,
CreatePaymentParams,
PaymentResult,
PaymentNotification,
} from "./adapter";
export interface EasyPayConfig {
apiUrl: string;
pid: string;
key: string;
}
export class EasyPayAdapter implements PaymentAdapter {
readonly name = "epay";
private config: EasyPayConfig;
constructor(config: EasyPayConfig) {
this.config = config;
}
async createPayment(params: CreatePaymentParams): Promise<PaymentResult> {
const type = params.channel === "wxpay" ? "wxpay" : "alipay";
const data: Record<string, string> = {
pid: this.config.pid,
type,
out_trade_no: params.tradeNo,
notify_url: params.notifyUrl,
return_url: params.returnUrl,
name: params.subject,
money: params.amount.toFixed(2),
};
data.sign = this.sign(data);
data.sign_type = "MD5";
// Try mapi.php first (returns JSON with payment URL), fall back to submit.php
try {
const qs = new URLSearchParams(data).toString();
const res = await fetchWithTimeout(
`${this.config.apiUrl}/mapi.php?${qs}`,
undefined,
15_000,
);
if (res.ok) {
const json = await res.json() as Record<string, unknown>;
if (json.code === 1 || json.code === "1") {
const paymentUrl =
(json.payurl as string) ||
(json.pay_url as string) ||
(json.qrcode as string) ||
(json.urlscheme as string) ||
"";
if (paymentUrl) {
return { success: true, paymentUrl, tradeNo: params.tradeNo };
}
}
}
} catch {
// mapi.php not available, fall back to submit.php redirect
}
// Fallback: direct redirect URL via submit.php
const qs = new URLSearchParams(data).toString();
const paymentUrl = `${this.config.apiUrl}/submit.php?${qs}`;
return { success: true, paymentUrl, tradeNo: params.tradeNo };
}
async verifyNotification(
body: Record<string, string>
): Promise<PaymentNotification | null> {
const { sign, sign_type, ...rest } = body;
void sign_type;
const expected = this.sign(rest);
if (sign !== expected) return null;
if (body.trade_status !== "TRADE_SUCCESS") return null;
return {
tradeNo: body.out_trade_no,
amount: parseFloat(body.money),
status: "success",
paymentRef: body.trade_no,
};
}
async queryOrder(tradeNo: string): Promise<PaymentNotification | null> {
const data: Record<string, string> = {
act: "order",
pid: this.config.pid,
out_trade_no: tradeNo,
};
data.sign = this.sign(data);
data.sign_type = "MD5";
const qs = new URLSearchParams(data).toString();
let res: Response;
try {
res = await fetchWithTimeout(
`${this.config.apiUrl}/api.php?${qs}`,
undefined,
15_000,
);
} catch {
// Network error — treat as unavailable, rely on callback notification
return null;
}
if (!res.ok) {
// Many epay platforms don't expose api.php — silently return null
// so the polling loop keeps waiting for the async notification instead
return null;
}
const json = await res.json();
if (json.code !== 1 || json.status !== 1) return null;
return {
tradeNo: json.out_trade_no,
amount: parseFloat(json.money),
status: "success",
paymentRef: json.trade_no,
};
}
private sign(params: Record<string, string>): string {
const sorted = Object.keys(params)
.filter((k) => params[k] !== "" && k !== "sign" && k !== "sign_type")
.sort();
const str = sorted.map((k) => `${k}=${params[k]}`).join("&");
return crypto.createHash("md5").update(str + this.config.key).digest("hex");
}
}

View File

@@ -0,0 +1,84 @@
import { prisma } from "@/lib/prisma";
import type { PaymentAdapter } from "./adapter";
import { EasyPayAdapter, type EasyPayConfig } from "./epay";
import { AlipayF2FAdapter, type AlipayF2FConfig } from "./alipay-f2f";
import { UsdtTrc20Adapter, type UsdtTrc20Config } from "./usdt-trc20";
import {
getPaymentProviderName,
parsePaymentConfig,
} from "./catalog";
export async function getPaymentAdapter(provider: string): Promise<PaymentAdapter> {
// epay_alipay / epay_wxpay both use the epay adapter
const realProvider = provider.startsWith("epay") ? "epay" : provider;
const config = await prisma.paymentConfig.findUnique({
where: { provider: realProvider },
});
if (!config || !config.enabled) {
throw new Error(`Payment provider "${provider}" is not configured or disabled`);
}
const cfg = parsePaymentConfig(
realProvider,
config.config as Record<string, string>,
);
switch (realProvider) {
case "epay":
return new EasyPayAdapter(cfg as EasyPayConfig);
case "alipay_f2f":
return new AlipayF2FAdapter(cfg as AlipayF2FConfig);
case "usdt_trc20":
return new UsdtTrc20Adapter(cfg as UsdtTrc20Config);
default:
throw new Error(`Unknown payment provider: ${provider}`);
}
}
export interface EnabledProvider {
provider: string;
name: string;
channel?: string;
}
export async function getEnabledProviders(): Promise<EnabledProvider[]> {
const configs = await prisma.paymentConfig.findMany({
where: { enabled: true },
select: { provider: true, config: true },
});
const result: EnabledProvider[] = [];
for (const c of configs) {
const cfg = c.config as Record<string, string> | null;
if (c.provider === "epay") {
// Read admin-configured channels (default: both)
const channelsStr = cfg?.channels || "alipay,wxpay";
const channels = channelsStr.split(",").map((s) => s.trim()).filter(Boolean);
const displayName = cfg?.displayName || "";
const channelLabels: Record<string, string> = {
alipay: "支付宝",
wxpay: "微信支付",
};
for (const ch of channels) {
const defaultLabel = channelLabels[ch] ?? ch;
const name = displayName
? channels.length > 1 ? `${displayName} (${defaultLabel})` : displayName
: defaultLabel;
result.push({ provider: "epay", name, channel: ch });
}
} else {
result.push({
provider: c.provider,
name: cfg?.displayName || getPaymentProviderName(c.provider),
});
}
}
return result;
}

View File

@@ -0,0 +1,164 @@
import type { OrderStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { getErrorMessage } from "@/lib/errors";
import { createNotification } from "@/services/notifications";
import { provisionSubscriptionWithDb } from "@/services/provision";
import { recordTaskFailure } from "@/services/task-center";
import { issueInviteRewardForOrder } from "@/services/invite-rewards";
export interface PaymentProcessResult {
processed: boolean;
finalStatus: OrderStatus | null;
errorMessage?: string;
}
interface PaymentProcessTxnResult extends PaymentProcessResult {
affectedNodeIds: string[];
}
async function processOrderPaymentById(
orderId: string,
paymentRef?: string,
): Promise<PaymentProcessResult> {
try {
const result = await prisma.$transaction<PaymentProcessTxnResult>(async (tx) => {
const claimed = await tx.order.updateMany({
where: {
id: orderId,
status: "PENDING",
},
data: {
status: "PAID",
note: null,
...(paymentRef !== undefined ? { paymentRef } : {}),
},
});
if (claimed.count === 0) {
const current = await tx.order.findUnique({
where: { id: orderId },
select: { status: true },
});
return {
processed: false,
finalStatus: current?.status ?? null,
affectedNodeIds: [],
};
}
const order = await tx.order.findUnique({
where: { id: orderId },
include: { plan: true, user: true },
});
if (!order || order.status !== "PAID") {
return { processed: false, finalStatus: order?.status ?? null, affectedNodeIds: [] };
}
const affectedNodeIds = await provisionSubscriptionWithDb(order, tx);
if (order.kind === "NEW_PURCHASE") {
await issueInviteRewardForOrder({ ...order, status: "PAID" }, tx);
}
return { processed: true, finalStatus: "PAID", affectedNodeIds };
});
return {
processed: result.processed,
finalStatus: result.finalStatus,
errorMessage: result.errorMessage,
};
} catch (error) {
const message = getErrorMessage(error, "开通失败");
const order = await prisma.order.findUnique({
where: { id: orderId },
select: {
id: true,
userId: true,
plan: {
select: {
name: true,
},
},
},
});
if (order) {
await prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id: order.id },
data: {
status: "PENDING",
note: `Provision failed: ${message}`,
},
});
await createNotification(
{
userId: order.userId,
type: "ORDER",
level: "ERROR",
title: "支付已确认,但开通失败",
body: `${order.plan.name} 支付成功,但开通时发生错误:${message}`,
link: `/pay/${order.id}`,
dedupeKey: `provision-failed:${order.id}`,
},
tx,
);
await recordTaskFailure(
{
kind: "ORDER_PROVISION_RETRY",
title: `订单 ${order.id} 开通失败待重试`,
targetType: "Order",
targetId: order.id,
payload: {
orderId: order.id,
},
retryable: true,
errorMessage: message,
},
tx,
);
});
}
return {
processed: false,
finalStatus: "PENDING",
errorMessage: message,
};
}
}
export async function handleVerifiedPaymentSuccess(
tradeNo: string,
paidAmount: number,
paymentRef?: string,
) {
if (!Number.isFinite(paidAmount) || paidAmount <= 0) {
return { processed: false, finalStatus: null } satisfies PaymentProcessResult;
}
const order = await prisma.order.findUnique({
where: { tradeNo },
select: { id: true, amount: true },
});
if (!order) {
return { processed: false, finalStatus: null } satisfies PaymentProcessResult;
}
const expectedAmount = Number(order.amount);
if (!Number.isFinite(expectedAmount) || Math.abs(expectedAmount - paidAmount) > 0.01) {
return {
processed: false,
finalStatus: null,
errorMessage: "支付金额与订单金额不一致",
} satisfies PaymentProcessResult;
}
return processOrderPaymentById(order.id, paymentRef);
}
export async function confirmPendingOrder(orderId: string) {
return processOrderPaymentById(orderId);
}

View File

@@ -0,0 +1,115 @@
import { fetchWithTimeout } from "@/lib/fetch-with-timeout";
import type {
PaymentAdapter,
CreatePaymentParams,
PaymentResult,
PaymentNotification,
} from "./adapter";
export interface UsdtTrc20Config {
walletAddress: string;
tronApiUrl?: string;
tronApiKey?: string;
usdtContract?: string;
exchangeRate: number;
}
const DEFAULT_TRON_API = "https://api.trongrid.io";
const USDT_TRC20_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
export class UsdtTrc20Adapter implements PaymentAdapter {
readonly name = "usdt_trc20";
private config: UsdtTrc20Config;
constructor(config: UsdtTrc20Config) {
this.config = {
walletAddress: config.walletAddress,
exchangeRate: config.exchangeRate,
tronApiUrl: config.tronApiUrl || DEFAULT_TRON_API,
tronApiKey: config.tronApiKey || "",
usdtContract: config.usdtContract || USDT_TRC20_CONTRACT,
};
}
async createPayment(params: CreatePaymentParams): Promise<PaymentResult> {
const usdtAmount = this.config.exchangeRate > 0
? (params.amount / this.config.exchangeRate).toFixed(2)
: params.amount.toFixed(2);
return {
success: true,
tradeNo: params.tradeNo,
qrCode: this.config.walletAddress,
raw: {
walletAddress: this.config.walletAddress,
usdtAmount,
cnyAmount: params.amount.toFixed(2),
exchangeRate: this.config.exchangeRate,
network: "TRC20",
},
};
}
async verifyNotification(): Promise<PaymentNotification | null> {
return null;
}
async queryOrder(tradeNo: string, createdAfter?: number): Promise<PaymentNotification | null> {
const expectedCny = this.parseAmountFromTradeNo(tradeNo);
if (!expectedCny) return null;
const expectedUsdt = this.config.exchangeRate > 0
? expectedCny / this.config.exchangeRate
: expectedCny;
const transfers = await this.getRecentTransfers();
for (const tx of transfers) {
if (createdAfter && tx.timestamp < createdAfter) continue;
const amount = tx.amount / 1e6;
if (Math.abs(amount - expectedUsdt) < 0.01) {
return {
tradeNo,
amount: expectedCny,
status: "success",
paymentRef: tx.txId,
};
}
}
return null;
}
private async getRecentTransfers(): Promise<Array<{ txId: string; amount: number; timestamp: number }>> {
const url = `${this.config.tronApiUrl}/v1/accounts/${this.config.walletAddress}/transactions/trc20?limit=20&contract_address=${this.config.usdtContract}&only_to=true`;
const headers: Record<string, string> = {};
if (this.config.tronApiKey) {
headers["TRON-PRO-API-KEY"] = this.config.tronApiKey;
}
const res = await fetchWithTimeout(url, { headers }, 15_000);
if (!res.ok) return [];
const json = await res.json();
if (!json.data) return [];
return json.data.map((tx: {
transaction_id: string;
value: string;
block_timestamp: number;
}) => ({
txId: tx.transaction_id,
amount: parseInt(tx.value),
timestamp: tx.block_timestamp,
}));
}
private parseAmountFromTradeNo(tradeNo: string): number | null {
const parts = tradeNo.split("-");
const amountStr = parts[parts.length - 1];
const amount = parseFloat(amountStr);
return isNaN(amount) ? null : amount;
}
}