mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 09:14:11 +05:30
Initial commit
This commit is contained in:
31
src/services/payment/adapter.ts
Normal file
31
src/services/payment/adapter.ts
Normal 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>;
|
||||
}
|
||||
161
src/services/payment/alipay-f2f.ts
Normal file
161
src/services/payment/alipay-f2f.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
134
src/services/payment/catalog.ts
Normal file
134
src/services/payment/catalog.ts
Normal 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);
|
||||
}
|
||||
139
src/services/payment/epay.ts
Normal file
139
src/services/payment/epay.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
84
src/services/payment/factory.ts
Normal file
84
src/services/payment/factory.ts
Normal 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;
|
||||
}
|
||||
164
src/services/payment/process.ts
Normal file
164
src/services/payment/process.ts
Normal 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);
|
||||
}
|
||||
115
src/services/payment/usdt-trc20.ts
Normal file
115
src/services/payment/usdt-trc20.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user