接入DuluPay充值闭环并统一升级弹窗比例
This commit is contained in:
parent
83d2a1574b
commit
6cac41f1c0
@ -83,6 +83,11 @@ import {
|
||||
type PointsPackage,
|
||||
type PointsSystemConfig,
|
||||
} from "@/lib/points/config";
|
||||
import {
|
||||
defaultDulupayConfig,
|
||||
normalizeDulupayConfig,
|
||||
type DulupayConfig,
|
||||
} from "@/lib/payment/dulupay-config";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
@ -193,6 +198,7 @@ type SiteSettings = {
|
||||
lovartComposerPreviewTexts: string[];
|
||||
pointsSystemConfig: PointsSystemConfig;
|
||||
pointsPackages: PointsPackage[];
|
||||
dulupayConfig: DulupayConfig;
|
||||
lovartPromoText: string;
|
||||
lovartPromoActionText: string;
|
||||
lovartHeroTitle: string;
|
||||
@ -433,6 +439,7 @@ export function AdminSettingsClient() {
|
||||
],
|
||||
pointsSystemConfig: defaultPointsSystemConfig,
|
||||
pointsPackages: defaultPointsPackages,
|
||||
dulupayConfig: defaultDulupayConfig,
|
||||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: "立即升级",
|
||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||
@ -460,6 +467,9 @@ export function AdminSettingsClient() {
|
||||
const normalizedPointsPackages = normalizePointsPackages(
|
||||
(siteSettingsData as any).pointsPackages
|
||||
);
|
||||
const normalizedDulupayConfig = normalizeDulupayConfig(
|
||||
(siteSettingsData as any).dulupayConfig
|
||||
);
|
||||
|
||||
setSiteSettings({
|
||||
siteName: siteSettingsData.siteName || "AI Chatbot",
|
||||
@ -499,6 +509,7 @@ export function AdminSettingsClient() {
|
||||
],
|
||||
pointsSystemConfig: normalizedPointsSystemConfig,
|
||||
pointsPackages: normalizedPointsPackages,
|
||||
dulupayConfig: normalizedDulupayConfig,
|
||||
lovartPromoText:
|
||||
siteSettingsData.lovartPromoText ||
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
@ -3648,6 +3659,249 @@ export function AdminSettingsClient() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">DuluPay 支付配置</Label>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
用于积分充值支付。支持 V1(MD5)和 V2(SHA256WithRSA)两种签名模式。
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: defaultDulupayConfig,
|
||||
})
|
||||
}
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="dulupayEnabled">启用支付</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
开启后允许前端发起 DuluPay 充值订单
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="dulupayEnabled"
|
||||
checked={siteSettings.dulupayConfig?.enabled === true}
|
||||
onCheckedChange={(checked) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
enabled: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>接口版本</Label>
|
||||
<Select
|
||||
value={siteSettings.dulupayConfig?.version || "v1"}
|
||||
onValueChange={(value: "v1" | "v2") =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
version: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="v1">V1 (MD5)</SelectItem>
|
||||
<SelectItem value="v2">V2 (SHA256WithRSA)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<Label>基础地址</Label>
|
||||
<Input
|
||||
value={siteSettings.dulupayConfig?.baseUrl || ""}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
baseUrl: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="https://www.dulupay.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>异步通知地址</Label>
|
||||
<Input
|
||||
value={siteSettings.dulupayConfig?.notifyUrl || ""}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
notifyUrl: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="https://your-domain.com/api/payment/dulupay/notify"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>同步返回地址</Label>
|
||||
<Input
|
||||
value={siteSettings.dulupayConfig?.returnUrl || ""}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
returnUrl: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="https://your-domain.com/payment/success"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>默认支付类型</Label>
|
||||
<Input
|
||||
value={siteSettings.dulupayConfig?.defaultType || ""}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
defaultType: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="alipay / wxpay"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{siteSettings.dulupayConfig?.version === "v1" ? (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>商户 PID</Label>
|
||||
<Input
|
||||
value={siteSettings.dulupayConfig?.pid || ""}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
pid: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="商户ID (pid)"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>商户 Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={siteSettings.dulupayConfig?.key || ""}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
key: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="V1 签名密钥 key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>商户号 mch_id</Label>
|
||||
<Input
|
||||
value={siteSettings.dulupayConfig?.mchId || ""}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
mchId: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="V2 商户号"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>应用 app_id</Label>
|
||||
<Input
|
||||
value={siteSettings.dulupayConfig?.appId || ""}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
appId: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="V2 应用ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>商户私钥(RSA)</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={siteSettings.dulupayConfig?.merchantPrivateKey || ""}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
merchantPrivateKey: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="-----BEGIN PRIVATE KEY-----"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>平台公钥(RSA)</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={siteSettings.dulupayConfig?.platformPublicKey || ""}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
dulupayConfig: {
|
||||
...siteSettings.dulupayConfig,
|
||||
platformPublicKey: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="-----BEGIN PUBLIC KEY-----"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="showAutoModel">显示"自动"模型选项</Label>
|
||||
|
||||
187
app/api/payment/dulupay/create/route.ts
Normal file
187
app/api/payment/dulupay/create/route.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { paymentOrder, systemSettings } from "@/lib/db/schema";
|
||||
import {
|
||||
DULUPAY_CONFIG_KEY,
|
||||
normalizeDulupayConfig,
|
||||
} from "@/lib/payment/dulupay-config";
|
||||
import {
|
||||
normalizeDulupayBaseUrl,
|
||||
signDulupayV1,
|
||||
} from "@/lib/payment/dulupay";
|
||||
import {
|
||||
POINTS_PACKAGES_KEY,
|
||||
normalizePointsPackages,
|
||||
} from "@/lib/points/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const createSchema = z.object({
|
||||
packageId: z.string().trim().min(1).max(64),
|
||||
payType: z.string().trim().min(1).max(32).optional(),
|
||||
});
|
||||
|
||||
function buildOutTradeNo() {
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
return `DP${Date.now()}${random}`.slice(0, 64);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = createSchema.parse(await request.json());
|
||||
|
||||
const [dulupaySetting, pointsPackagesSetting] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, DULUPAY_CONFIG_KEY))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]),
|
||||
db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, POINTS_PACKAGES_KEY))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]),
|
||||
]);
|
||||
|
||||
const dulupayConfig = normalizeDulupayConfig(dulupaySetting?.value);
|
||||
if (!dulupayConfig.enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "DuluPay 未启用" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (dulupayConfig.version !== "v1") {
|
||||
return NextResponse.json(
|
||||
{ error: "当前仅支持 DuluPay V1 支付" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!dulupayConfig.pid || !dulupayConfig.key) {
|
||||
return NextResponse.json(
|
||||
{ error: "DuluPay 配置不完整,请补充 PID 和 Key" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const packages = normalizePointsPackages(pointsPackagesSetting?.value);
|
||||
const selectedPackage = packages.find(
|
||||
(item) => item.id === body.packageId && item.isEnabled !== false
|
||||
);
|
||||
|
||||
if (!selectedPackage) {
|
||||
return NextResponse.json(
|
||||
{ error: "积分套餐不存在或未启用" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPackage.price <= 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "该套餐金额无效" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const outTradeNo = buildOutTradeNo();
|
||||
const amount = Number(selectedPackage.price.toFixed(2));
|
||||
const amountCents = Math.round(amount * 100);
|
||||
const payType = body.payType || dulupayConfig.defaultType || "alipay";
|
||||
|
||||
const defaultOrigin = new URL(request.url).origin;
|
||||
const notifyUrl =
|
||||
dulupayConfig.notifyUrl || `${defaultOrigin}/api/payment/dulupay/notify`;
|
||||
const returnUrl = dulupayConfig.returnUrl || `${defaultOrigin}/`;
|
||||
|
||||
const baseParams = {
|
||||
pid: dulupayConfig.pid,
|
||||
type: payType,
|
||||
out_trade_no: outTradeNo,
|
||||
notify_url: notifyUrl,
|
||||
return_url: returnUrl,
|
||||
name: `积分充值-${selectedPackage.name}`,
|
||||
money: amount.toFixed(2),
|
||||
param: `${session.user.id}:${selectedPackage.id}`,
|
||||
};
|
||||
|
||||
const sign = signDulupayV1(baseParams, dulupayConfig.key);
|
||||
const params = new URLSearchParams({
|
||||
...baseParams,
|
||||
sign,
|
||||
sign_type: "MD5",
|
||||
});
|
||||
|
||||
const paymentUrl = `${normalizeDulupayBaseUrl(
|
||||
dulupayConfig.baseUrl
|
||||
)}/submit.php?${params.toString()}`;
|
||||
|
||||
const existing = await db
|
||||
.select({ id: paymentOrder.id })
|
||||
.from(paymentOrder)
|
||||
.where(
|
||||
and(
|
||||
eq(paymentOrder.outTradeNo, outTradeNo),
|
||||
eq(paymentOrder.provider, "dulupay")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "订单创建失败,请重试" }, { status: 500 });
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(paymentOrder)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
provider: "dulupay",
|
||||
outTradeNo,
|
||||
packageId: selectedPackage.id,
|
||||
points: selectedPackage.points,
|
||||
amountCents,
|
||||
currency: selectedPackage.currency,
|
||||
payType,
|
||||
status: "pending",
|
||||
paymentUrl,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({
|
||||
id: paymentOrder.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
orderId: created.id,
|
||||
outTradeNo,
|
||||
paymentUrl,
|
||||
amount,
|
||||
currency: selectedPackage.currency,
|
||||
points: selectedPackage.points,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "请求参数错误",
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[DuluPay Create] Error:", error);
|
||||
return NextResponse.json({ error: "创建支付订单失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
215
app/api/payment/dulupay/notify/route.ts
Normal file
215
app/api/payment/dulupay/notify/route.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { paymentOrder, pointsLedger, systemSettings, user } from "@/lib/db/schema";
|
||||
import {
|
||||
DULUPAY_CONFIG_KEY,
|
||||
normalizeDulupayConfig,
|
||||
} from "@/lib/payment/dulupay-config";
|
||||
import { verifyDulupayV1Sign } from "@/lib/payment/dulupay";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function plainText(text: string, status = 200) {
|
||||
return new NextResponse(text, {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function readNotifyParams(request: NextRequest) {
|
||||
const url = new URL(request.url);
|
||||
const record: Record<string, string> = {};
|
||||
|
||||
url.searchParams.forEach((value, key) => {
|
||||
record[key] = value;
|
||||
});
|
||||
|
||||
if (request.method === "GET") {
|
||||
return record;
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = (await request.json().catch(() => null)) as
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
if (data) {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
record[key] = String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
const text = await request.text();
|
||||
if (!text) return record;
|
||||
|
||||
const formParams = new URLSearchParams(text);
|
||||
formParams.forEach((value, key) => {
|
||||
record[key] = value;
|
||||
});
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async function handleNotify(request: NextRequest) {
|
||||
const params = await readNotifyParams(request);
|
||||
const outTradeNo = String(params.out_trade_no || "").trim();
|
||||
const tradeNo = String(params.trade_no || "").trim();
|
||||
const tradeStatus = String(params.trade_status || "").trim();
|
||||
const moneyRaw = String(params.money || "").trim();
|
||||
|
||||
if (!outTradeNo) {
|
||||
return plainText("fail", 400);
|
||||
}
|
||||
|
||||
const setting = await db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, DULUPAY_CONFIG_KEY))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const config = normalizeDulupayConfig(setting?.value);
|
||||
if (!config.enabled || config.version !== "v1" || !config.key) {
|
||||
return plainText("fail", 400);
|
||||
}
|
||||
|
||||
if (!verifyDulupayV1Sign(params, config.key)) {
|
||||
return plainText("fail", 400);
|
||||
}
|
||||
|
||||
const order = await db
|
||||
.select({
|
||||
id: paymentOrder.id,
|
||||
userId: paymentOrder.userId,
|
||||
status: paymentOrder.status,
|
||||
points: paymentOrder.points,
|
||||
amountCents: paymentOrder.amountCents,
|
||||
})
|
||||
.from(paymentOrder)
|
||||
.where(
|
||||
and(
|
||||
eq(paymentOrder.outTradeNo, outTradeNo),
|
||||
eq(paymentOrder.provider, "dulupay")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (!order) {
|
||||
return plainText("fail", 404);
|
||||
}
|
||||
|
||||
const callbackAmountCents = Number.isFinite(Number(moneyRaw))
|
||||
? Math.round(Number(moneyRaw) * 100)
|
||||
: null;
|
||||
|
||||
if (
|
||||
callbackAmountCents !== null &&
|
||||
callbackAmountCents > 0 &&
|
||||
callbackAmountCents !== order.amountCents
|
||||
) {
|
||||
await db
|
||||
.update(paymentOrder)
|
||||
.set({
|
||||
status: "failed",
|
||||
tradeNo: tradeNo || null,
|
||||
errorMessage: `回调金额不一致: ${moneyRaw}`,
|
||||
rawCallback: params,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(paymentOrder.id, order.id), ne(paymentOrder.status, "paid")));
|
||||
|
||||
return plainText("fail", 400);
|
||||
}
|
||||
|
||||
if (tradeStatus === "TRADE_SUCCESS") {
|
||||
await db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(paymentOrder)
|
||||
.set({
|
||||
status: "paid",
|
||||
tradeNo: tradeNo || null,
|
||||
rawCallback: params,
|
||||
errorMessage: null,
|
||||
paidAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(eq(paymentOrder.id, order.id), ne(paymentOrder.status, "paid"))
|
||||
)
|
||||
.returning({
|
||||
id: paymentOrder.id,
|
||||
userId: paymentOrder.userId,
|
||||
points: paymentOrder.points,
|
||||
outTradeNo: paymentOrder.outTradeNo,
|
||||
});
|
||||
|
||||
if (updated.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paidOrder = updated[0];
|
||||
|
||||
if (paidOrder.points > 0) {
|
||||
await tx
|
||||
.update(user)
|
||||
.set({
|
||||
pointsBalance: sql`${user.pointsBalance} + ${paidOrder.points}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(user.id, paidOrder.userId));
|
||||
|
||||
await tx.insert(pointsLedger).values({
|
||||
userId: paidOrder.userId,
|
||||
amount: paidOrder.points,
|
||||
reason: "recharge",
|
||||
note: `DuluPay 充值 (${paidOrder.outTradeNo})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return plainText("success");
|
||||
}
|
||||
|
||||
const mappedStatus = tradeStatus === "TRADE_CLOSED" ? "closed" : "failed";
|
||||
|
||||
await db
|
||||
.update(paymentOrder)
|
||||
.set({
|
||||
status: mappedStatus,
|
||||
tradeNo: tradeNo || null,
|
||||
errorMessage: tradeStatus ? `trade_status=${tradeStatus}` : "支付失败",
|
||||
rawCallback: params,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(paymentOrder.id, order.id), ne(paymentOrder.status, "paid")));
|
||||
|
||||
return plainText("success");
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
return await handleNotify(request);
|
||||
} catch (error) {
|
||||
console.error("[DuluPay Notify] GET error:", error);
|
||||
return plainText("fail", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
return await handleNotify(request);
|
||||
} catch (error) {
|
||||
console.error("[DuluPay Notify] POST error:", error);
|
||||
return plainText("fail", 500);
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,12 @@ import {
|
||||
POINTS_PACKAGES_KEY,
|
||||
POINTS_SYSTEM_CONFIG_KEY,
|
||||
} from "@/lib/points/config";
|
||||
import {
|
||||
defaultDulupayConfig,
|
||||
DULUPAY_CONFIG_KEY,
|
||||
normalizeDulupayConfig,
|
||||
normalizeDulupayForPublic,
|
||||
} from "@/lib/payment/dulupay-config";
|
||||
|
||||
const HOME_VARIANT_KEY = "home_variant";
|
||||
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
|
||||
@ -85,6 +91,8 @@ function normalizeLovartComposerPrefix(value: unknown): string {
|
||||
// GET /api/site-settings - 获取网站设置 (公开)
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth().catch(() => null);
|
||||
const isAdmin = session?.user?.type === "admin";
|
||||
const [
|
||||
settings,
|
||||
notificationSetting,
|
||||
@ -96,6 +104,7 @@ export async function GET() {
|
||||
membershipOverridesSetting,
|
||||
pointsSystemConfigSetting,
|
||||
pointsPackagesSetting,
|
||||
dulupayConfigSetting,
|
||||
] = await Promise.all([
|
||||
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
|
||||
db
|
||||
@ -152,6 +161,12 @@ export async function GET() {
|
||||
.where(eq(systemSettings.key, POINTS_PACKAGES_KEY))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]),
|
||||
db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, DULUPAY_CONFIG_KEY))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]),
|
||||
]);
|
||||
|
||||
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
|
||||
@ -171,6 +186,9 @@ export async function GET() {
|
||||
pointsSystemConfigSetting?.value
|
||||
);
|
||||
const pointsPackages = normalizePointsPackages(pointsPackagesSetting?.value);
|
||||
const dulupayConfig = isAdmin
|
||||
? normalizeDulupayConfig(dulupayConfigSetting?.value)
|
||||
: normalizeDulupayForPublic(dulupayConfigSetting?.value);
|
||||
|
||||
// 如果没有设置记录,返回默认值
|
||||
const baseSettings = settings || {
|
||||
@ -209,6 +227,7 @@ export async function GET() {
|
||||
membershipUserOverrides,
|
||||
pointsSystemConfig,
|
||||
pointsPackages,
|
||||
dulupayConfig,
|
||||
};
|
||||
|
||||
const response = NextResponse.json({
|
||||
@ -228,6 +247,7 @@ export async function GET() {
|
||||
membershipUserOverrides,
|
||||
pointsSystemConfig,
|
||||
pointsPackages,
|
||||
dulupayConfig,
|
||||
});
|
||||
|
||||
// 禁用缓存,确保总是获取最新设置
|
||||
@ -281,6 +301,7 @@ const updateSchema = z.object({
|
||||
membershipUserOverrides: z.unknown().optional(),
|
||||
pointsSystemConfig: z.unknown().optional(),
|
||||
pointsPackages: z.unknown().optional(),
|
||||
dulupayConfig: z.unknown().optional(),
|
||||
});
|
||||
|
||||
// PUT /api/site-settings - 更新网站设置(仅管理员)
|
||||
@ -307,6 +328,7 @@ export async function PUT(request: NextRequest) {
|
||||
membershipUserOverrides,
|
||||
pointsSystemConfig,
|
||||
pointsPackages,
|
||||
dulupayConfig,
|
||||
...siteSettingsPayload
|
||||
} = data;
|
||||
|
||||
@ -484,6 +506,26 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (dulupayConfig !== undefined) {
|
||||
const normalized = normalizeDulupayConfig(dulupayConfig);
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key: DULUPAY_CONFIG_KEY,
|
||||
value: normalized,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: systemSettings.key,
|
||||
set: {
|
||||
value: normalized,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...updated,
|
||||
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
|
||||
@ -515,6 +557,10 @@ export async function PUT(request: NextRequest) {
|
||||
pointsPackages !== undefined
|
||||
? normalizePointsPackages(pointsPackages)
|
||||
: defaultPointsPackages,
|
||||
dulupayConfig:
|
||||
dulupayConfig !== undefined
|
||||
? normalizeDulupayConfig(dulupayConfig)
|
||||
: defaultDulupayConfig,
|
||||
});
|
||||
} else {
|
||||
// 创建新设置
|
||||
@ -713,6 +759,26 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (dulupayConfig !== undefined) {
|
||||
const normalized = normalizeDulupayConfig(dulupayConfig);
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key: DULUPAY_CONFIG_KEY,
|
||||
value: normalized,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: systemSettings.key,
|
||||
set: {
|
||||
value: normalized,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...created,
|
||||
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
|
||||
@ -744,6 +810,10 @@ export async function PUT(request: NextRequest) {
|
||||
pointsPackages !== undefined
|
||||
? normalizePointsPackages(pointsPackages)
|
||||
: defaultPointsPackages,
|
||||
dulupayConfig:
|
||||
dulupayConfig !== undefined
|
||||
? normalizeDulupayConfig(dulupayConfig)
|
||||
: defaultDulupayConfig,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { systemSettings } from "@/lib/db/schema";
|
||||
import { paymentOrder, systemSettings } from "@/lib/db/schema";
|
||||
import {
|
||||
getPointsLedgerByUserId,
|
||||
getPointsSystemConfig,
|
||||
@ -35,11 +35,29 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const [dbUser, pointsConfig, ledger, configSetting, overridesSetting] =
|
||||
const [
|
||||
dbUser,
|
||||
pointsConfig,
|
||||
ledger,
|
||||
paymentOrders,
|
||||
configSetting,
|
||||
overridesSetting,
|
||||
] =
|
||||
await Promise.all([
|
||||
getUserById({ id: session.user.id }),
|
||||
getPointsSystemConfig(),
|
||||
getPointsLedgerByUserId({ userId: session.user.id, limit: 200 }),
|
||||
db
|
||||
.select({
|
||||
id: paymentOrder.id,
|
||||
amountCents: paymentOrder.amountCents,
|
||||
status: paymentOrder.status,
|
||||
createdAt: paymentOrder.createdAt,
|
||||
})
|
||||
.from(paymentOrder)
|
||||
.where(eq(paymentOrder.userId, session.user.id))
|
||||
.orderBy(desc(paymentOrder.createdAt))
|
||||
.limit(100),
|
||||
db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
@ -76,17 +94,35 @@ export async function GET() {
|
||||
createdAt: item.createdAt?.toISOString() || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const billingRecords = ledger
|
||||
.filter((item) => item.reason === "recharge" || item.reason === "gift")
|
||||
const rechargeRecords = paymentOrders.map((order) => ({
|
||||
id: order.id,
|
||||
date: order.createdAt?.toISOString() || new Date().toISOString(),
|
||||
category: "积分充值",
|
||||
amount: Number((order.amountCents / 100).toFixed(2)),
|
||||
status:
|
||||
order.status === "paid"
|
||||
? "completed"
|
||||
: order.status === "pending"
|
||||
? "pending"
|
||||
: "failed",
|
||||
invoice: "-",
|
||||
}));
|
||||
|
||||
const giftedRecords = ledger
|
||||
.filter((item) => item.reason === "gift")
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
date: item.createdAt?.toISOString() || new Date().toISOString(),
|
||||
category: item.reason === "recharge" ? "积分充值" : "积分赠送",
|
||||
category: "积分赠送",
|
||||
amount: item.amount,
|
||||
status: "completed",
|
||||
invoice: "-",
|
||||
}));
|
||||
|
||||
const billingRecords = [...rechargeRecords, ...giftedRecords].sort((a, b) => {
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
profile: {
|
||||
name: dbUser?.name || session.user.name || "",
|
||||
@ -110,4 +146,3 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
app/api/user/payment-orders/route.ts
Normal file
44
app/api/user/payment-orders/route.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { paymentOrder } from "@/lib/db/schema";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const orders = await db
|
||||
.select({
|
||||
id: paymentOrder.id,
|
||||
provider: paymentOrder.provider,
|
||||
outTradeNo: paymentOrder.outTradeNo,
|
||||
tradeNo: paymentOrder.tradeNo,
|
||||
packageId: paymentOrder.packageId,
|
||||
points: paymentOrder.points,
|
||||
amountCents: paymentOrder.amountCents,
|
||||
currency: paymentOrder.currency,
|
||||
payType: paymentOrder.payType,
|
||||
status: paymentOrder.status,
|
||||
paidAt: paymentOrder.paidAt,
|
||||
createdAt: paymentOrder.createdAt,
|
||||
})
|
||||
.from(paymentOrder)
|
||||
.where(eq(paymentOrder.userId, session.user.id))
|
||||
.orderBy(desc(paymentOrder.createdAt))
|
||||
.limit(200);
|
||||
|
||||
return NextResponse.json({
|
||||
orders: orders.map((order) => ({
|
||||
...order,
|
||||
amount: Number((order.amountCents / 100).toFixed(2)),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Payment Orders API] Error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch payment orders" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -133,6 +133,15 @@ type MembershipSummaryResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
type PointsPackage = {
|
||||
id: string;
|
||||
name: string;
|
||||
points: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
type AccountCenterResponse = {
|
||||
profile: {
|
||||
name: string;
|
||||
@ -300,6 +309,7 @@ export function LovartHome({
|
||||
);
|
||||
const [usageFilter, setUsageFilter] = useState<"all" | "consumed" | "earned">("all");
|
||||
const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly");
|
||||
const [creatingPaymentPackageId, setCreatingPaymentPackageId] = useState<string | null>(null);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>("");
|
||||
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
|
||||
aspectRatio: "auto",
|
||||
@ -367,6 +377,11 @@ export function LovartHome({
|
||||
toolbox1Enabled?: boolean;
|
||||
toolbox2Name?: string | null;
|
||||
toolbox2Enabled?: boolean;
|
||||
pointsPackages?: PointsPackage[];
|
||||
dulupayConfig?: {
|
||||
enabled?: boolean;
|
||||
defaultType?: string;
|
||||
};
|
||||
}>("/api/site-settings", fetcher);
|
||||
|
||||
const { data: authSettingsData } = useSWR<PublicAuthSettings>(
|
||||
@ -505,6 +520,12 @@ export function LovartHome({
|
||||
return records.filter((item) => item.status === usageFilter);
|
||||
}, [accountCenterData?.usageRecords, usageFilter]);
|
||||
|
||||
const enabledPointsPackages = useMemo(() => {
|
||||
return (siteSettingsData?.pointsPackages || []).filter(
|
||||
(item) => item?.isEnabled !== false && item.price > 0 && item.points > 0
|
||||
);
|
||||
}, [siteSettingsData?.pointsPackages]);
|
||||
|
||||
const toolbox1Links = useMemo(
|
||||
() =>
|
||||
(navLinksData?.links || [])
|
||||
@ -768,6 +789,38 @@ export function LovartHome({
|
||||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
const handleCreateDulupayOrder = async (packageId: string) => {
|
||||
if (!isAuthenticated) {
|
||||
openLoginDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (creatingPaymentPackageId) return;
|
||||
|
||||
setCreatingPaymentPackageId(packageId);
|
||||
try {
|
||||
const response = await fetch("/api/payment/dulupay/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
packageId,
|
||||
payType: siteSettingsData?.dulupayConfig?.defaultType || "alipay",
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result?.paymentUrl) {
|
||||
throw new Error(result?.error || "创建支付订单失败");
|
||||
}
|
||||
|
||||
window.location.href = result.paymentUrl;
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "创建支付订单失败");
|
||||
} finally {
|
||||
setCreatingPaymentPackageId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const createCanvasFromComposer = async () => {
|
||||
if (!isAuthenticated) {
|
||||
openLoginDialog();
|
||||
@ -1580,29 +1633,29 @@ export function LovartHome({
|
||||
) : null}
|
||||
|
||||
<Dialog open={accountCenterOpen} onOpenChange={setAccountCenterOpen}>
|
||||
<DialogContent className="max-h-[90vh] w-[94vw] max-w-[1080px] overflow-hidden rounded-[24px] border-black/10 bg-[#f6f6f7] p-0">
|
||||
<DialogContent className="max-h-[88vh] w-[92vw] max-w-[980px] overflow-hidden rounded-[22px] border-black/10 bg-[#f6f6f7] p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>账户管理</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex h-full max-h-[90vh] flex-col p-5">
|
||||
<div className="mb-3 flex items-center gap-6 border-b border-black/8 pb-2.5">
|
||||
<div className="flex h-full max-h-[88vh] flex-col p-4">
|
||||
<div className="mb-2.5 flex items-center gap-5 border-b border-black/8 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`text-[15px] font-semibold leading-tight ${accountCenterTab === "account" ? "text-black" : "text-black/35"}`}
|
||||
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "account" ? "text-black" : "text-black/35"}`}
|
||||
onClick={() => setAccountCenterTab("account")}
|
||||
>
|
||||
账户管理
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`text-[15px] font-semibold leading-tight ${accountCenterTab === "usage" ? "text-black" : "text-black/35"}`}
|
||||
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "usage" ? "text-black" : "text-black/35"}`}
|
||||
onClick={() => setAccountCenterTab("usage")}
|
||||
>
|
||||
用量
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`text-[15px] font-semibold leading-tight ${accountCenterTab === "billing" ? "text-black" : "text-black/35"}`}
|
||||
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "billing" ? "text-black" : "text-black/35"}`}
|
||||
onClick={() => setAccountCenterTab("billing")}
|
||||
>
|
||||
账单
|
||||
@ -1612,14 +1665,14 @@ export function LovartHome({
|
||||
<div className="flex-1 overflow-y-auto pr-1">
|
||||
{accountCenterTab === "account" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[18px] border border-black/10 bg-white px-4 py-3.5">
|
||||
<div className="rounded-[16px] border border-black/10 bg-white px-3.5 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#d4551f] text-[16px] font-semibold text-white">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
|
||||
{userInitials}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[15px] font-semibold text-black">
|
||||
<div className="truncate text-[14px] font-semibold text-black">
|
||||
{accountProfile.name}
|
||||
</div>
|
||||
<div className="truncate text-[12px] text-black/45">{accountProfile.email}</div>
|
||||
@ -1628,14 +1681,14 @@ export function LovartHome({
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 rounded-xl bg-[#2f3542] px-3.5 text-[13px] text-white hover:bg-[#242a36] hover:text-white"
|
||||
className="h-8 rounded-xl bg-[#2f3542] px-3 text-[12px] text-white hover:bg-[#242a36] hover:text-white"
|
||||
onClick={() => toast.info("编辑资料功能后续开放")}
|
||||
>
|
||||
编辑资料
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 rounded-xl border border-black/10 bg-white px-3.5 text-[13px] text-black/75 hover:bg-black/5"
|
||||
className="h-8 rounded-xl border border-black/10 bg-white px-3 text-[12px] text-black/75 hover:bg-black/5"
|
||||
onClick={() => {
|
||||
void signOut({ redirect: true, callbackUrl: "/" });
|
||||
}}
|
||||
@ -1646,12 +1699,12 @@ export function LovartHome({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[18px] border border-black/10 bg-white px-4 py-4">
|
||||
<div className="mb-2.5 flex items-center gap-5 text-[14px] font-semibold">
|
||||
<div className="rounded-[16px] border border-black/10 bg-white px-3.5 py-3.5">
|
||||
<div className="mb-2 flex items-center gap-4 text-[13px] font-semibold">
|
||||
<span className="text-black">账户信息</span>
|
||||
<span className="text-black/30">设备管理</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[120px_1fr] gap-y-2.5 text-[13px]">
|
||||
<div className="grid grid-cols-[110px_1fr] gap-y-2 text-[12px]">
|
||||
<div className="text-black/55">当前订阅</div>
|
||||
<div className="text-black">{accountProfile.planName}</div>
|
||||
<div className="text-black/55">可用积分</div>
|
||||
@ -1666,11 +1719,11 @@ export function LovartHome({
|
||||
) : null}
|
||||
|
||||
{accountCenterTab === "usage" ? (
|
||||
<div className="rounded-[18px] border border-black/10 bg-white">
|
||||
<div className="grid grid-cols-[2fr_1fr_2fr_1fr] items-center border-b border-black/8 px-4 py-2.5 text-[13px] font-semibold text-black/80">
|
||||
<div className="rounded-[16px] border border-black/10 bg-white">
|
||||
<div className="grid grid-cols-[2fr_1fr_2fr_1fr] items-center border-b border-black/8 px-3.5 py-2 text-[12px] font-semibold text-black/80">
|
||||
<span>明细</span>
|
||||
<Select value={usageFilter} onValueChange={(value: "all" | "consumed" | "earned") => setUsageFilter(value)}>
|
||||
<SelectTrigger className="h-7 w-[110px] border-black/10 bg-white text-[12px]">
|
||||
<SelectTrigger className="h-7 w-[104px] border-black/10 bg-white text-[12px]">
|
||||
<span>{usageFilter === "all" ? "全部" : usageFilter === "consumed" ? "已消耗" : "已获取"}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -1687,7 +1740,7 @@ export function LovartHome({
|
||||
{filteredUsageRecords.map((record, idx) => (
|
||||
<div
|
||||
key={record.id}
|
||||
className={`grid grid-cols-[2fr_1fr_2fr_1fr] items-center px-4 py-2.5 text-[13px] ${idx % 2 === 1 ? "bg-black/[0.02]" : ""}`}
|
||||
className={`grid grid-cols-[2fr_1fr_2fr_1fr] items-center px-3.5 py-2 text-[12px] ${idx % 2 === 1 ? "bg-black/[0.02]" : ""}`}
|
||||
>
|
||||
<div className="truncate pr-3 text-black/80">{record.detail}</div>
|
||||
<div className="text-black/55">{record.status === "consumed" ? "已消耗" : "已获取"}</div>
|
||||
@ -1705,8 +1758,8 @@ export function LovartHome({
|
||||
) : null}
|
||||
|
||||
{accountCenterTab === "billing" ? (
|
||||
<div className="rounded-[18px] border border-black/10 bg-white">
|
||||
<div className="grid grid-cols-[2fr_2fr_1.5fr_1.5fr_1fr] items-center border-b border-black/8 px-4 py-2.5 text-[13px] font-semibold text-black/80">
|
||||
<div className="rounded-[16px] border border-black/10 bg-white">
|
||||
<div className="grid grid-cols-[2fr_2fr_1.5fr_1.5fr_1fr] items-center border-b border-black/8 px-3.5 py-2 text-[12px] font-semibold text-black/80">
|
||||
<span>日期</span>
|
||||
<span>类别</span>
|
||||
<span>金额</span>
|
||||
@ -1718,7 +1771,7 @@ export function LovartHome({
|
||||
{accountCenterData.billingRecords.map((record, idx) => (
|
||||
<div
|
||||
key={record.id}
|
||||
className={`grid grid-cols-[2fr_2fr_1.5fr_1.5fr_1fr] items-center px-4 py-2.5 text-[13px] ${idx % 2 === 1 ? "bg-black/[0.02]" : ""}`}
|
||||
className={`grid grid-cols-[2fr_2fr_1.5fr_1.5fr_1fr] items-center px-3.5 py-2 text-[12px] ${idx % 2 === 1 ? "bg-black/[0.02]" : ""}`}
|
||||
>
|
||||
<div className="text-black/65">{formatDateTime(record.date)}</div>
|
||||
<div className="text-black/80">{record.category}</div>
|
||||
@ -1739,34 +1792,80 @@ export function LovartHome({
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen}>
|
||||
<DialogContent className="max-h-[92vh] w-[95vw] max-w-[1240px] overflow-hidden rounded-3xl border-black/10 bg-[#f7f7f7] p-0">
|
||||
<div className="flex h-full max-h-[92vh] flex-col">
|
||||
<DialogHeader className="border-b border-black/6 px-6 py-5">
|
||||
<DialogTitle className="text-center text-5xl font-bold text-black">升级您的套餐</DialogTitle>
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
<DialogContent className="max-h-[90vh] w-[94vw] max-w-[980px] overflow-hidden rounded-2xl border-black/10 bg-[#f7f7f7] p-0">
|
||||
<div className="flex h-full max-h-[90vh] flex-col">
|
||||
<DialogHeader className="border-b border-black/6 px-5 py-4">
|
||||
<DialogTitle className="text-center text-3xl font-bold text-black">升级您的套餐</DialogTitle>
|
||||
<div className="mt-2.5 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant={billingCycle === "monthly" ? "default" : "outline"}
|
||||
className="h-8 rounded-full px-4 text-xs"
|
||||
className="h-7 rounded-full px-3.5 text-[11px]"
|
||||
onClick={() => setBillingCycle("monthly")}
|
||||
>
|
||||
月付
|
||||
</Button>
|
||||
<Button
|
||||
variant={billingCycle === "annual" ? "default" : "outline"}
|
||||
className="h-8 rounded-full px-4 text-xs"
|
||||
className="h-7 rounded-full px-3.5 text-[11px]"
|
||||
onClick={() => setBillingCycle("annual")}
|
||||
>
|
||||
年付
|
||||
</Button>
|
||||
<span className="rounded-full bg-[#ffe68f] px-2 py-1 text-xs text-[#8b5e00]">
|
||||
<span className="rounded-full bg-[#ffe68f] px-2 py-0.5 text-[11px] text-[#8b5e00]">
|
||||
{membershipConfig?.annualDiscountLabel || "限时优惠"}
|
||||
</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 pb-8 pt-4">
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-3.5">
|
||||
{siteSettingsData?.dulupayConfig?.enabled === true &&
|
||||
enabledPointsPackages.length > 0 ? (
|
||||
<section className="mb-5 rounded-2xl border border-black/10 bg-white p-3.5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="text-base font-semibold text-black">积分充值</h4>
|
||||
<span className="text-[11px] text-black/45">支持 DuluPay 即时到账</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2.5 md:grid-cols-3">
|
||||
{enabledPointsPackages.map((pkg) => {
|
||||
const isPaying = creatingPaymentPackageId === pkg.id;
|
||||
return (
|
||||
<div
|
||||
key={pkg.id}
|
||||
className="rounded-xl border border-black/10 bg-[#fafafa] p-3"
|
||||
>
|
||||
<div className="text-sm font-semibold text-black">{pkg.name}</div>
|
||||
<div className="mt-1 text-xs text-black/55">
|
||||
{pkg.points.toLocaleString()} 积分
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-bold text-black">
|
||||
{formatPrice(
|
||||
pkg.currency === "CNY" ? "¥" : `${pkg.currency} `,
|
||||
pkg.price
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-2.5 h-8 w-full rounded-lg bg-black text-xs text-white hover:bg-black/90"
|
||||
disabled={isPaying}
|
||||
onClick={() => handleCreateDulupayOrder(pkg.id)}
|
||||
>
|
||||
{isPaying ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
跳转中
|
||||
</span>
|
||||
) : (
|
||||
"立即充值"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{membershipPlans.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
{membershipPlans.map((plan) => {
|
||||
const isCurrent = currentPlan?.id === plan.id;
|
||||
const annualMonthlyPrice =
|
||||
@ -1774,45 +1873,45 @@ export function LovartHome({
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`rounded-2xl border bg-white p-4 ${
|
||||
className={`rounded-xl border bg-white p-3 ${
|
||||
plan.highlighted
|
||||
? "border-[#7d87ff] shadow-[0_0_0_2px_rgba(125,135,255,0.25)]"
|
||||
: "border-black/10"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-3xl font-semibold text-black">{plan.name}</h3>
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-black">{plan.name}</h3>
|
||||
{plan.badge ? (
|
||||
<span className="rounded-full bg-[#eef0ff] px-2 py-1 text-[10px] text-[#5b65db]">
|
||||
<span className="rounded-full bg-[#eef0ff] px-2 py-0.5 text-[10px] text-[#5b65db]">
|
||||
{plan.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="mb-2.5">
|
||||
{plan.monthlyPrice <= 0 ? (
|
||||
<div className="text-4xl font-bold text-black">免费</div>
|
||||
<div className="text-3xl font-bold text-black">免费</div>
|
||||
) : billingCycle === "monthly" ? (
|
||||
<div className="text-4xl font-bold text-black">
|
||||
<div className="text-3xl font-bold text-black">
|
||||
{formatPrice(membershipConfig?.currency || "$", plan.monthlyPrice)}
|
||||
<span className="ml-1 text-sm font-normal text-black/45">/月</span>
|
||||
<span className="ml-1 text-xs font-normal text-black/45">/月</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-black">
|
||||
<div className="text-3xl font-bold text-black">
|
||||
{formatPrice(membershipConfig?.currency || "$", plan.annualPrice)}
|
||||
<span className="ml-1 text-sm font-normal text-black/45">/年</span>
|
||||
<span className="ml-1 text-xs font-normal text-black/45">/年</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-black/45">
|
||||
<div className="mt-1 text-[11px] text-black/45">
|
||||
折合 {formatPrice(membershipConfig?.currency || "$", annualMonthlyPrice)}/月
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-4 text-sm text-black/60">
|
||||
<div className="mb-3 text-xs text-black/60">
|
||||
每月 {plan.monthlyCredits.toLocaleString()} 快速积分
|
||||
</div>
|
||||
<Button
|
||||
className="mb-4 h-9 w-full rounded-xl bg-black text-white hover:bg-black/90"
|
||||
className="mb-3 h-8 w-full rounded-lg bg-black text-xs text-white hover:bg-black/90"
|
||||
disabled={isCurrent}
|
||||
onClick={() => {
|
||||
if (isCurrent) return;
|
||||
@ -1821,10 +1920,10 @@ export function LovartHome({
|
||||
>
|
||||
{isCurrent ? "当前套餐" : "升级"}
|
||||
</Button>
|
||||
<ul className="space-y-1.5 text-xs text-black/65">
|
||||
<ul className="space-y-1 text-[11px] text-black/65">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-black/40" />
|
||||
<span className="mt-1 h-1 w-1 rounded-full bg-black/40" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
@ -1839,13 +1938,13 @@ export function LovartHome({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mt-10">
|
||||
<h4 className="mb-4 text-center text-4xl font-semibold text-black">常见问题</h4>
|
||||
<section className="mt-7">
|
||||
<h4 className="mb-3 text-center text-2xl font-semibold text-black">常见问题</h4>
|
||||
<div className="space-y-2">
|
||||
{(membershipConfig?.faqs || []).map((faq) => (
|
||||
<details
|
||||
key={faq.question}
|
||||
className="rounded-xl border border-black/8 bg-white px-4 py-3 text-sm text-black/70"
|
||||
className="rounded-xl border border-black/8 bg-white px-3.5 py-2.5 text-xs text-black/70"
|
||||
>
|
||||
<summary className="cursor-pointer list-none font-medium">{faq.question}</summary>
|
||||
<p className="mt-2 text-black/60">{faq.answer}</p>
|
||||
@ -1854,20 +1953,20 @@ export function LovartHome({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h4 className="mb-2 text-center text-4xl font-semibold text-black">
|
||||
<section className="mt-7">
|
||||
<h4 className="mb-2 text-center text-2xl font-semibold text-black">
|
||||
模型积分消耗 & 每月生成数量
|
||||
</h4>
|
||||
<p className="mb-4 text-center text-xs text-black/45">
|
||||
<p className="mb-3 text-center text-[11px] text-black/45">
|
||||
以下表格基于系统设置中的会员配置,可直接在后台调整。
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-2xl border border-black/10 bg-white">
|
||||
<table className="min-w-[900px] w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-xl border border-black/10 bg-white">
|
||||
<table className="w-full min-w-[820px] text-xs">
|
||||
<thead>
|
||||
<tr className="bg-black/[0.03]">
|
||||
<th className="px-3 py-3 text-left font-semibold text-black/75">模型</th>
|
||||
<th className="px-2.5 py-2.5 text-left font-semibold text-black/75">模型</th>
|
||||
{membershipPlans.map((plan) => (
|
||||
<th key={plan.id} className="px-3 py-3 text-left font-semibold text-black/75">
|
||||
<th key={plan.id} className="px-2.5 py-2.5 text-left font-semibold text-black/75">
|
||||
{plan.name}
|
||||
</th>
|
||||
))}
|
||||
@ -1879,16 +1978,16 @@ export function LovartHome({
|
||||
<tr className="bg-black/[0.045]">
|
||||
<td
|
||||
colSpan={membershipPlans.length + 1}
|
||||
className="px-3 py-2 text-xs font-semibold text-black/60"
|
||||
className="px-2.5 py-2 text-[11px] font-semibold text-black/60"
|
||||
>
|
||||
{category}
|
||||
</td>
|
||||
</tr>
|
||||
{rows.map((row) => (
|
||||
<tr key={`${category}-${row.model}`} className="border-t border-black/[0.06]">
|
||||
<td className="px-3 py-2 text-black/70">{row.model}</td>
|
||||
<td className="px-2.5 py-2 text-black/70">{row.model}</td>
|
||||
{membershipPlans.map((plan) => (
|
||||
<td key={`${row.model}-${plan.id}`} className="px-3 py-2 text-black/60">
|
||||
<td key={`${row.model}-${plan.id}`} className="px-2.5 py-2 text-black/60">
|
||||
{row.values[plan.id] ?? "-"}
|
||||
{row.values[plan.id] && row.values[plan.id] !== "-" ? row.unit : ""}
|
||||
</td>
|
||||
|
||||
30
lib/db/migrations/0036_add_payment_orders.sql
Normal file
30
lib/db/migrations/0036_add_payment_orders.sql
Normal file
@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS "PaymentOrder" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE cascade,
|
||||
"provider" varchar(32) NOT NULL DEFAULT 'dulupay',
|
||||
"outTradeNo" varchar(128) NOT NULL,
|
||||
"tradeNo" varchar(128),
|
||||
"packageId" varchar(64),
|
||||
"points" integer NOT NULL DEFAULT 0,
|
||||
"amountCents" integer NOT NULL DEFAULT 0,
|
||||
"currency" varchar(8) NOT NULL DEFAULT 'CNY',
|
||||
"payType" varchar(32),
|
||||
"status" varchar(32) NOT NULL DEFAULT 'pending',
|
||||
"paymentUrl" text,
|
||||
"rawCallback" jsonb,
|
||||
"errorMessage" text,
|
||||
"paidAt" timestamp,
|
||||
"createdAt" timestamp NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamp NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PaymentOrder_status_check" CHECK ("status" IN ('pending', 'paid', 'failed', 'closed')),
|
||||
CONSTRAINT "PaymentOrder_out_trade_no_unique" UNIQUE("outTradeNo")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "payment_order_user_created_idx"
|
||||
ON "PaymentOrder" ("userId", "createdAt" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "payment_order_out_trade_idx"
|
||||
ON "PaymentOrder" ("outTradeNo");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "payment_order_trade_no_idx"
|
||||
ON "PaymentOrder" ("tradeNo");
|
||||
@ -155,6 +155,13 @@
|
||||
"when": 1771150800000,
|
||||
"tag": "0035_add_points_system",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1771209600000,
|
||||
"tag": "0036_add_payment_orders",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -51,6 +51,30 @@ export const pointsLedger = pgTable("PointsLedger", {
|
||||
|
||||
export type PointsLedger = InferSelectModel<typeof pointsLedger>;
|
||||
|
||||
export const paymentOrder = pgTable("PaymentOrder", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
userId: uuid("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
provider: varchar("provider", { length: 32 }).notNull().default("dulupay"),
|
||||
outTradeNo: varchar("outTradeNo", { length: 128 }).notNull().unique(),
|
||||
tradeNo: varchar("tradeNo", { length: 128 }),
|
||||
packageId: varchar("packageId", { length: 64 }),
|
||||
points: integer("points").notNull().default(0),
|
||||
amountCents: integer("amountCents").notNull().default(0),
|
||||
currency: varchar("currency", { length: 8 }).notNull().default("CNY"),
|
||||
payType: varchar("payType", { length: 32 }),
|
||||
status: varchar("status", { length: 32 }).notNull().default("pending"),
|
||||
paymentUrl: text("paymentUrl"),
|
||||
rawCallback: jsonb("rawCallback"),
|
||||
errorMessage: text("errorMessage"),
|
||||
paidAt: timestamp("paidAt"),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type PaymentOrder = InferSelectModel<typeof paymentOrder>;
|
||||
|
||||
export const chat = pgTable("Chat", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
createdAt: timestamp("createdAt").notNull(),
|
||||
|
||||
75
lib/payment/dulupay-config.ts
Normal file
75
lib/payment/dulupay-config.ts
Normal file
@ -0,0 +1,75 @@
|
||||
export const DULUPAY_CONFIG_KEY = "payment_dulupay_config";
|
||||
|
||||
export type DulupayConfig = {
|
||||
enabled: boolean;
|
||||
version: "v1" | "v2";
|
||||
baseUrl: string;
|
||||
notifyUrl: string;
|
||||
returnUrl: string;
|
||||
// V1
|
||||
pid: string;
|
||||
key: string;
|
||||
defaultType: string;
|
||||
// V2
|
||||
mchId: string;
|
||||
appId: string;
|
||||
merchantPrivateKey: string;
|
||||
platformPublicKey: string;
|
||||
};
|
||||
|
||||
export const defaultDulupayConfig: DulupayConfig = {
|
||||
enabled: false,
|
||||
version: "v1",
|
||||
baseUrl: "https://www.dulupay.com",
|
||||
notifyUrl: "",
|
||||
returnUrl: "",
|
||||
pid: "",
|
||||
key: "",
|
||||
defaultType: "alipay",
|
||||
mchId: "",
|
||||
appId: "",
|
||||
merchantPrivateKey: "",
|
||||
platformPublicKey: "",
|
||||
};
|
||||
|
||||
function sanitizeText(value: unknown, maxLength: number) {
|
||||
return String(value || "").trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
export function normalizeDulupayConfig(value: unknown): DulupayConfig {
|
||||
if (!value || typeof value !== "object") {
|
||||
return defaultDulupayConfig;
|
||||
}
|
||||
|
||||
const raw = value as Partial<DulupayConfig>;
|
||||
const version = raw.version === "v2" ? "v2" : "v1";
|
||||
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
version,
|
||||
baseUrl:
|
||||
sanitizeText(raw.baseUrl, 255) || defaultDulupayConfig.baseUrl,
|
||||
notifyUrl: sanitizeText(raw.notifyUrl, 500),
|
||||
returnUrl: sanitizeText(raw.returnUrl, 500),
|
||||
pid: sanitizeText(raw.pid, 128),
|
||||
key: sanitizeText(raw.key, 500),
|
||||
defaultType: sanitizeText(raw.defaultType, 64) || "alipay",
|
||||
mchId: sanitizeText(raw.mchId, 128),
|
||||
appId: sanitizeText(raw.appId, 128),
|
||||
merchantPrivateKey: sanitizeText(raw.merchantPrivateKey, 6000),
|
||||
platformPublicKey: sanitizeText(raw.platformPublicKey, 6000),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDulupayForPublic(value: unknown): DulupayConfig {
|
||||
const config = normalizeDulupayConfig(value);
|
||||
return {
|
||||
...config,
|
||||
pid: "",
|
||||
key: "",
|
||||
mchId: "",
|
||||
appId: "",
|
||||
merchantPrivateKey: "",
|
||||
platformPublicKey: "",
|
||||
};
|
||||
}
|
||||
44
lib/payment/dulupay.ts
Normal file
44
lib/payment/dulupay.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function normalizeDulupayBaseUrl(baseUrl: string) {
|
||||
const trimmed = String(baseUrl || "").trim();
|
||||
if (!trimmed) return "https://www.dulupay.com";
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function toSignSource(
|
||||
params: Record<string, string | number | null | undefined>,
|
||||
key: string
|
||||
) {
|
||||
const payload = Object.entries(params)
|
||||
.filter(
|
||||
([name, value]) =>
|
||||
name !== "sign" &&
|
||||
name !== "sign_type" &&
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
String(value) !== ""
|
||||
)
|
||||
.sort(([a], [b]) => (a > b ? 1 : a < b ? -1 : 0))
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join("&");
|
||||
|
||||
return `${payload}${key}`;
|
||||
}
|
||||
|
||||
export function signDulupayV1(
|
||||
params: Record<string, string | number | null | undefined>,
|
||||
key: string
|
||||
) {
|
||||
return createHash("md5").update(toSignSource(params, key), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export function verifyDulupayV1Sign(
|
||||
params: Record<string, string>,
|
||||
key: string
|
||||
) {
|
||||
const incomingSign = String(params.sign || "").trim().toLowerCase();
|
||||
if (!incomingSign) return false;
|
||||
const localSign = signDulupayV1(params, key);
|
||||
return incomingSign === localSign;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user