接入DuluPay充值闭环并统一升级弹窗比例
This commit is contained in:
parent
83d2a1574b
commit
6cac41f1c0
@ -83,6 +83,11 @@ import {
|
|||||||
type PointsPackage,
|
type PointsPackage,
|
||||||
type PointsSystemConfig,
|
type PointsSystemConfig,
|
||||||
} from "@/lib/points/config";
|
} from "@/lib/points/config";
|
||||||
|
import {
|
||||||
|
defaultDulupayConfig,
|
||||||
|
normalizeDulupayConfig,
|
||||||
|
type DulupayConfig,
|
||||||
|
} from "@/lib/payment/dulupay-config";
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||||
|
|
||||||
@ -193,6 +198,7 @@ type SiteSettings = {
|
|||||||
lovartComposerPreviewTexts: string[];
|
lovartComposerPreviewTexts: string[];
|
||||||
pointsSystemConfig: PointsSystemConfig;
|
pointsSystemConfig: PointsSystemConfig;
|
||||||
pointsPackages: PointsPackage[];
|
pointsPackages: PointsPackage[];
|
||||||
|
dulupayConfig: DulupayConfig;
|
||||||
lovartPromoText: string;
|
lovartPromoText: string;
|
||||||
lovartPromoActionText: string;
|
lovartPromoActionText: string;
|
||||||
lovartHeroTitle: string;
|
lovartHeroTitle: string;
|
||||||
@ -433,6 +439,7 @@ export function AdminSettingsClient() {
|
|||||||
],
|
],
|
||||||
pointsSystemConfig: defaultPointsSystemConfig,
|
pointsSystemConfig: defaultPointsSystemConfig,
|
||||||
pointsPackages: defaultPointsPackages,
|
pointsPackages: defaultPointsPackages,
|
||||||
|
dulupayConfig: defaultDulupayConfig,
|
||||||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
lovartPromoActionText: "立即升级",
|
lovartPromoActionText: "立即升级",
|
||||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||||
@ -460,6 +467,9 @@ export function AdminSettingsClient() {
|
|||||||
const normalizedPointsPackages = normalizePointsPackages(
|
const normalizedPointsPackages = normalizePointsPackages(
|
||||||
(siteSettingsData as any).pointsPackages
|
(siteSettingsData as any).pointsPackages
|
||||||
);
|
);
|
||||||
|
const normalizedDulupayConfig = normalizeDulupayConfig(
|
||||||
|
(siteSettingsData as any).dulupayConfig
|
||||||
|
);
|
||||||
|
|
||||||
setSiteSettings({
|
setSiteSettings({
|
||||||
siteName: siteSettingsData.siteName || "AI Chatbot",
|
siteName: siteSettingsData.siteName || "AI Chatbot",
|
||||||
@ -499,6 +509,7 @@ export function AdminSettingsClient() {
|
|||||||
],
|
],
|
||||||
pointsSystemConfig: normalizedPointsSystemConfig,
|
pointsSystemConfig: normalizedPointsSystemConfig,
|
||||||
pointsPackages: normalizedPointsPackages,
|
pointsPackages: normalizedPointsPackages,
|
||||||
|
dulupayConfig: normalizedDulupayConfig,
|
||||||
lovartPromoText:
|
lovartPromoText:
|
||||||
siteSettingsData.lovartPromoText ||
|
siteSettingsData.lovartPromoText ||
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
@ -3648,6 +3659,249 @@ export function AdminSettingsClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="showAutoModel">显示"自动"模型选项</Label>
|
<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_PACKAGES_KEY,
|
||||||
POINTS_SYSTEM_CONFIG_KEY,
|
POINTS_SYSTEM_CONFIG_KEY,
|
||||||
} from "@/lib/points/config";
|
} from "@/lib/points/config";
|
||||||
|
import {
|
||||||
|
defaultDulupayConfig,
|
||||||
|
DULUPAY_CONFIG_KEY,
|
||||||
|
normalizeDulupayConfig,
|
||||||
|
normalizeDulupayForPublic,
|
||||||
|
} from "@/lib/payment/dulupay-config";
|
||||||
|
|
||||||
const HOME_VARIANT_KEY = "home_variant";
|
const HOME_VARIANT_KEY = "home_variant";
|
||||||
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
|
// 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 - 获取网站设置 (公开)
|
// GET /api/site-settings - 获取网站设置 (公开)
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
const session = await auth().catch(() => null);
|
||||||
|
const isAdmin = session?.user?.type === "admin";
|
||||||
const [
|
const [
|
||||||
settings,
|
settings,
|
||||||
notificationSetting,
|
notificationSetting,
|
||||||
@ -96,6 +104,7 @@ export async function GET() {
|
|||||||
membershipOverridesSetting,
|
membershipOverridesSetting,
|
||||||
pointsSystemConfigSetting,
|
pointsSystemConfigSetting,
|
||||||
pointsPackagesSetting,
|
pointsPackagesSetting,
|
||||||
|
dulupayConfigSetting,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
|
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
|
||||||
db
|
db
|
||||||
@ -152,6 +161,12 @@ export async function GET() {
|
|||||||
.where(eq(systemSettings.key, POINTS_PACKAGES_KEY))
|
.where(eq(systemSettings.key, POINTS_PACKAGES_KEY))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.then(rows => rows[0]),
|
.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: "" };
|
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
|
||||||
@ -171,6 +186,9 @@ export async function GET() {
|
|||||||
pointsSystemConfigSetting?.value
|
pointsSystemConfigSetting?.value
|
||||||
);
|
);
|
||||||
const pointsPackages = normalizePointsPackages(pointsPackagesSetting?.value);
|
const pointsPackages = normalizePointsPackages(pointsPackagesSetting?.value);
|
||||||
|
const dulupayConfig = isAdmin
|
||||||
|
? normalizeDulupayConfig(dulupayConfigSetting?.value)
|
||||||
|
: normalizeDulupayForPublic(dulupayConfigSetting?.value);
|
||||||
|
|
||||||
// 如果没有设置记录,返回默认值
|
// 如果没有设置记录,返回默认值
|
||||||
const baseSettings = settings || {
|
const baseSettings = settings || {
|
||||||
@ -209,6 +227,7 @@ export async function GET() {
|
|||||||
membershipUserOverrides,
|
membershipUserOverrides,
|
||||||
pointsSystemConfig,
|
pointsSystemConfig,
|
||||||
pointsPackages,
|
pointsPackages,
|
||||||
|
dulupayConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
@ -228,6 +247,7 @@ export async function GET() {
|
|||||||
membershipUserOverrides,
|
membershipUserOverrides,
|
||||||
pointsSystemConfig,
|
pointsSystemConfig,
|
||||||
pointsPackages,
|
pointsPackages,
|
||||||
|
dulupayConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 禁用缓存,确保总是获取最新设置
|
// 禁用缓存,确保总是获取最新设置
|
||||||
@ -281,6 +301,7 @@ const updateSchema = z.object({
|
|||||||
membershipUserOverrides: z.unknown().optional(),
|
membershipUserOverrides: z.unknown().optional(),
|
||||||
pointsSystemConfig: z.unknown().optional(),
|
pointsSystemConfig: z.unknown().optional(),
|
||||||
pointsPackages: z.unknown().optional(),
|
pointsPackages: z.unknown().optional(),
|
||||||
|
dulupayConfig: z.unknown().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/site-settings - 更新网站设置(仅管理员)
|
// PUT /api/site-settings - 更新网站设置(仅管理员)
|
||||||
@ -307,6 +328,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
membershipUserOverrides,
|
membershipUserOverrides,
|
||||||
pointsSystemConfig,
|
pointsSystemConfig,
|
||||||
pointsPackages,
|
pointsPackages,
|
||||||
|
dulupayConfig,
|
||||||
...siteSettingsPayload
|
...siteSettingsPayload
|
||||||
} = data;
|
} = 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({
|
return NextResponse.json({
|
||||||
...updated,
|
...updated,
|
||||||
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
|
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
|
||||||
@ -515,6 +557,10 @@ export async function PUT(request: NextRequest) {
|
|||||||
pointsPackages !== undefined
|
pointsPackages !== undefined
|
||||||
? normalizePointsPackages(pointsPackages)
|
? normalizePointsPackages(pointsPackages)
|
||||||
: defaultPointsPackages,
|
: defaultPointsPackages,
|
||||||
|
dulupayConfig:
|
||||||
|
dulupayConfig !== undefined
|
||||||
|
? normalizeDulupayConfig(dulupayConfig)
|
||||||
|
: defaultDulupayConfig,
|
||||||
});
|
});
|
||||||
} else {
|
} 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({
|
return NextResponse.json({
|
||||||
...created,
|
...created,
|
||||||
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
|
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
|
||||||
@ -744,6 +810,10 @@ export async function PUT(request: NextRequest) {
|
|||||||
pointsPackages !== undefined
|
pointsPackages !== undefined
|
||||||
? normalizePointsPackages(pointsPackages)
|
? normalizePointsPackages(pointsPackages)
|
||||||
: defaultPointsPackages,
|
: defaultPointsPackages,
|
||||||
|
dulupayConfig:
|
||||||
|
dulupayConfig !== undefined
|
||||||
|
? normalizeDulupayConfig(dulupayConfig)
|
||||||
|
: defaultDulupayConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { systemSettings } from "@/lib/db/schema";
|
import { paymentOrder, systemSettings } from "@/lib/db/schema";
|
||||||
import {
|
import {
|
||||||
getPointsLedgerByUserId,
|
getPointsLedgerByUserId,
|
||||||
getPointsSystemConfig,
|
getPointsSystemConfig,
|
||||||
@ -35,11 +35,29 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [dbUser, pointsConfig, ledger, configSetting, overridesSetting] =
|
const [
|
||||||
|
dbUser,
|
||||||
|
pointsConfig,
|
||||||
|
ledger,
|
||||||
|
paymentOrders,
|
||||||
|
configSetting,
|
||||||
|
overridesSetting,
|
||||||
|
] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getUserById({ id: session.user.id }),
|
getUserById({ id: session.user.id }),
|
||||||
getPointsSystemConfig(),
|
getPointsSystemConfig(),
|
||||||
getPointsLedgerByUserId({ userId: session.user.id, limit: 200 }),
|
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
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(systemSettings)
|
.from(systemSettings)
|
||||||
@ -76,17 +94,35 @@ export async function GET() {
|
|||||||
createdAt: item.createdAt?.toISOString() || new Date().toISOString(),
|
createdAt: item.createdAt?.toISOString() || new Date().toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const billingRecords = ledger
|
const rechargeRecords = paymentOrders.map((order) => ({
|
||||||
.filter((item) => item.reason === "recharge" || item.reason === "gift")
|
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) => ({
|
.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
date: item.createdAt?.toISOString() || new Date().toISOString(),
|
date: item.createdAt?.toISOString() || new Date().toISOString(),
|
||||||
category: item.reason === "recharge" ? "积分充值" : "积分赠送",
|
category: "积分赠送",
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
invoice: "-",
|
invoice: "-",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const billingRecords = [...rechargeRecords, ...giftedRecords].sort((a, b) => {
|
||||||
|
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
profile: {
|
profile: {
|
||||||
name: dbUser?.name || session.user.name || "",
|
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 = {
|
type AccountCenterResponse = {
|
||||||
profile: {
|
profile: {
|
||||||
name: string;
|
name: string;
|
||||||
@ -300,6 +309,7 @@ export function LovartHome({
|
|||||||
);
|
);
|
||||||
const [usageFilter, setUsageFilter] = useState<"all" | "consumed" | "earned">("all");
|
const [usageFilter, setUsageFilter] = useState<"all" | "consumed" | "earned">("all");
|
||||||
const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly");
|
const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly");
|
||||||
|
const [creatingPaymentPackageId, setCreatingPaymentPackageId] = useState<string | null>(null);
|
||||||
const [selectedModelId, setSelectedModelId] = useState<string>("");
|
const [selectedModelId, setSelectedModelId] = useState<string>("");
|
||||||
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
|
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
|
||||||
aspectRatio: "auto",
|
aspectRatio: "auto",
|
||||||
@ -367,6 +377,11 @@ export function LovartHome({
|
|||||||
toolbox1Enabled?: boolean;
|
toolbox1Enabled?: boolean;
|
||||||
toolbox2Name?: string | null;
|
toolbox2Name?: string | null;
|
||||||
toolbox2Enabled?: boolean;
|
toolbox2Enabled?: boolean;
|
||||||
|
pointsPackages?: PointsPackage[];
|
||||||
|
dulupayConfig?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
defaultType?: string;
|
||||||
|
};
|
||||||
}>("/api/site-settings", fetcher);
|
}>("/api/site-settings", fetcher);
|
||||||
|
|
||||||
const { data: authSettingsData } = useSWR<PublicAuthSettings>(
|
const { data: authSettingsData } = useSWR<PublicAuthSettings>(
|
||||||
@ -505,6 +520,12 @@ export function LovartHome({
|
|||||||
return records.filter((item) => item.status === usageFilter);
|
return records.filter((item) => item.status === usageFilter);
|
||||||
}, [accountCenterData?.usageRecords, 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(
|
const toolbox1Links = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(navLinksData?.links || [])
|
(navLinksData?.links || [])
|
||||||
@ -768,6 +789,38 @@ export function LovartHome({
|
|||||||
window.location.href = "/login";
|
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 () => {
|
const createCanvasFromComposer = async () => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
openLoginDialog();
|
openLoginDialog();
|
||||||
@ -1580,29 +1633,29 @@ export function LovartHome({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Dialog open={accountCenterOpen} onOpenChange={setAccountCenterOpen}>
|
<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">
|
<DialogHeader className="sr-only">
|
||||||
<DialogTitle>账户管理</DialogTitle>
|
<DialogTitle>账户管理</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex h-full max-h-[90vh] flex-col p-5">
|
<div className="flex h-full max-h-[88vh] flex-col p-4">
|
||||||
<div className="mb-3 flex items-center gap-6 border-b border-black/8 pb-2.5">
|
<div className="mb-2.5 flex items-center gap-5 border-b border-black/8 pb-2">
|
||||||
<button
|
<button
|
||||||
type="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")}
|
onClick={() => setAccountCenterTab("account")}
|
||||||
>
|
>
|
||||||
账户管理
|
账户管理
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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")}
|
onClick={() => setAccountCenterTab("usage")}
|
||||||
>
|
>
|
||||||
用量
|
用量
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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")}
|
onClick={() => setAccountCenterTab("billing")}
|
||||||
>
|
>
|
||||||
账单
|
账单
|
||||||
@ -1612,14 +1665,14 @@ export function LovartHome({
|
|||||||
<div className="flex-1 overflow-y-auto pr-1">
|
<div className="flex-1 overflow-y-auto pr-1">
|
||||||
{accountCenterTab === "account" ? (
|
{accountCenterTab === "account" ? (
|
||||||
<div className="space-y-4">
|
<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 items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center 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}
|
{userInitials}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<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}
|
{accountProfile.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-[12px] text-black/45">{accountProfile.email}</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">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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("编辑资料功能后续开放")}
|
onClick={() => toast.info("编辑资料功能后续开放")}
|
||||||
>
|
>
|
||||||
编辑资料
|
编辑资料
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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={() => {
|
onClick={() => {
|
||||||
void signOut({ redirect: true, callbackUrl: "/" });
|
void signOut({ redirect: true, callbackUrl: "/" });
|
||||||
}}
|
}}
|
||||||
@ -1646,12 +1699,12 @@ export function LovartHome({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-[18px] border border-black/10 bg-white px-4 py-4">
|
<div className="rounded-[16px] border border-black/10 bg-white px-3.5 py-3.5">
|
||||||
<div className="mb-2.5 flex items-center gap-5 text-[14px] font-semibold">
|
<div className="mb-2 flex items-center gap-4 text-[13px] font-semibold">
|
||||||
<span className="text-black">账户信息</span>
|
<span className="text-black">账户信息</span>
|
||||||
<span className="text-black/30">设备管理</span>
|
<span className="text-black/30">设备管理</span>
|
||||||
</div>
|
</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/55">当前订阅</div>
|
||||||
<div className="text-black">{accountProfile.planName}</div>
|
<div className="text-black">{accountProfile.planName}</div>
|
||||||
<div className="text-black/55">可用积分</div>
|
<div className="text-black/55">可用积分</div>
|
||||||
@ -1666,11 +1719,11 @@ export function LovartHome({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{accountCenterTab === "usage" ? (
|
{accountCenterTab === "usage" ? (
|
||||||
<div className="rounded-[18px] border border-black/10 bg-white">
|
<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-4 py-2.5 text-[13px] font-semibold text-black/80">
|
<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>
|
<span>明细</span>
|
||||||
<Select value={usageFilter} onValueChange={(value: "all" | "consumed" | "earned") => setUsageFilter(value)}>
|
<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>
|
<span>{usageFilter === "all" ? "全部" : usageFilter === "consumed" ? "已消耗" : "已获取"}</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -1687,7 +1740,7 @@ export function LovartHome({
|
|||||||
{filteredUsageRecords.map((record, idx) => (
|
{filteredUsageRecords.map((record, idx) => (
|
||||||
<div
|
<div
|
||||||
key={record.id}
|
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="truncate pr-3 text-black/80">{record.detail}</div>
|
||||||
<div className="text-black/55">{record.status === "consumed" ? "已消耗" : "已获取"}</div>
|
<div className="text-black/55">{record.status === "consumed" ? "已消耗" : "已获取"}</div>
|
||||||
@ -1705,8 +1758,8 @@ export function LovartHome({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{accountCenterTab === "billing" ? (
|
{accountCenterTab === "billing" ? (
|
||||||
<div className="rounded-[18px] border border-black/10 bg-white">
|
<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-4 py-2.5 text-[13px] font-semibold text-black/80">
|
<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>
|
<span>类别</span>
|
||||||
<span>金额</span>
|
<span>金额</span>
|
||||||
@ -1718,7 +1771,7 @@ export function LovartHome({
|
|||||||
{accountCenterData.billingRecords.map((record, idx) => (
|
{accountCenterData.billingRecords.map((record, idx) => (
|
||||||
<div
|
<div
|
||||||
key={record.id}
|
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/65">{formatDateTime(record.date)}</div>
|
||||||
<div className="text-black/80">{record.category}</div>
|
<div className="text-black/80">{record.category}</div>
|
||||||
@ -1739,34 +1792,80 @@ export function LovartHome({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen}>
|
<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">
|
<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-[92vh] flex-col">
|
<div className="flex h-full max-h-[90vh] flex-col">
|
||||||
<DialogHeader className="border-b border-black/6 px-6 py-5">
|
<DialogHeader className="border-b border-black/6 px-5 py-4">
|
||||||
<DialogTitle className="text-center text-5xl font-bold text-black">升级您的套餐</DialogTitle>
|
<DialogTitle className="text-center text-3xl font-bold text-black">升级您的套餐</DialogTitle>
|
||||||
<div className="mt-3 flex items-center justify-center gap-2">
|
<div className="mt-2.5 flex items-center justify-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={billingCycle === "monthly" ? "default" : "outline"}
|
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")}
|
onClick={() => setBillingCycle("monthly")}
|
||||||
>
|
>
|
||||||
月付
|
月付
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={billingCycle === "annual" ? "default" : "outline"}
|
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")}
|
onClick={() => setBillingCycle("annual")}
|
||||||
>
|
>
|
||||||
年付
|
年付
|
||||||
</Button>
|
</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 || "限时优惠"}
|
{membershipConfig?.annualDiscountLabel || "限时优惠"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</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 ? (
|
{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) => {
|
{membershipPlans.map((plan) => {
|
||||||
const isCurrent = currentPlan?.id === plan.id;
|
const isCurrent = currentPlan?.id === plan.id;
|
||||||
const annualMonthlyPrice =
|
const annualMonthlyPrice =
|
||||||
@ -1774,45 +1873,45 @@ export function LovartHome({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
className={`rounded-2xl border bg-white p-4 ${
|
className={`rounded-xl border bg-white p-3 ${
|
||||||
plan.highlighted
|
plan.highlighted
|
||||||
? "border-[#7d87ff] shadow-[0_0_0_2px_rgba(125,135,255,0.25)]"
|
? "border-[#7d87ff] shadow-[0_0_0_2px_rgba(125,135,255,0.25)]"
|
||||||
: "border-black/10"
|
: "border-black/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-2.5 flex items-center justify-between">
|
||||||
<h3 className="text-3xl font-semibold text-black">{plan.name}</h3>
|
<h3 className="text-lg font-semibold text-black">{plan.name}</h3>
|
||||||
{plan.badge ? (
|
{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}
|
{plan.badge}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-2.5">
|
||||||
{plan.monthlyPrice <= 0 ? (
|
{plan.monthlyPrice <= 0 ? (
|
||||||
<div className="text-4xl font-bold text-black">免费</div>
|
<div className="text-3xl font-bold text-black">免费</div>
|
||||||
) : billingCycle === "monthly" ? (
|
) : billingCycle === "monthly" ? (
|
||||||
<div className="text-4xl font-bold text-black">
|
<div className="text-3xl font-bold text-black">
|
||||||
{formatPrice(membershipConfig?.currency || "$", plan.monthlyPrice)}
|
{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>
|
<div>
|
||||||
<div className="text-4xl font-bold text-black">
|
<div className="text-3xl font-bold text-black">
|
||||||
{formatPrice(membershipConfig?.currency || "$", plan.annualPrice)}
|
{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>
|
||||||
<div className="mt-1 text-xs text-black/45">
|
<div className="mt-1 text-[11px] text-black/45">
|
||||||
折合 {formatPrice(membershipConfig?.currency || "$", annualMonthlyPrice)}/月
|
折合 {formatPrice(membershipConfig?.currency || "$", annualMonthlyPrice)}/月
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 text-sm text-black/60">
|
<div className="mb-3 text-xs text-black/60">
|
||||||
每月 {plan.monthlyCredits.toLocaleString()} 快速积分
|
每月 {plan.monthlyCredits.toLocaleString()} 快速积分
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<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}
|
disabled={isCurrent}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isCurrent) return;
|
if (isCurrent) return;
|
||||||
@ -1821,10 +1920,10 @@ export function LovartHome({
|
|||||||
>
|
>
|
||||||
{isCurrent ? "当前套餐" : "升级"}
|
{isCurrent ? "当前套餐" : "升级"}
|
||||||
</Button>
|
</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) => (
|
{plan.features.map((feature) => (
|
||||||
<li key={feature} className="flex items-start gap-2">
|
<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>
|
<span>{feature}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@ -1839,13 +1938,13 @@ export function LovartHome({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="mt-10">
|
<section className="mt-7">
|
||||||
<h4 className="mb-4 text-center text-4xl font-semibold text-black">常见问题</h4>
|
<h4 className="mb-3 text-center text-2xl font-semibold text-black">常见问题</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(membershipConfig?.faqs || []).map((faq) => (
|
{(membershipConfig?.faqs || []).map((faq) => (
|
||||||
<details
|
<details
|
||||||
key={faq.question}
|
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>
|
<summary className="cursor-pointer list-none font-medium">{faq.question}</summary>
|
||||||
<p className="mt-2 text-black/60">{faq.answer}</p>
|
<p className="mt-2 text-black/60">{faq.answer}</p>
|
||||||
@ -1854,20 +1953,20 @@ export function LovartHome({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-10">
|
<section className="mt-7">
|
||||||
<h4 className="mb-2 text-center text-4xl font-semibold text-black">
|
<h4 className="mb-2 text-center text-2xl font-semibold text-black">
|
||||||
模型积分消耗 & 每月生成数量
|
模型积分消耗 & 每月生成数量
|
||||||
</h4>
|
</h4>
|
||||||
<p className="mb-4 text-center text-xs text-black/45">
|
<p className="mb-3 text-center text-[11px] text-black/45">
|
||||||
以下表格基于系统设置中的会员配置,可直接在后台调整。
|
以下表格基于系统设置中的会员配置,可直接在后台调整。
|
||||||
</p>
|
</p>
|
||||||
<div className="overflow-x-auto rounded-2xl border border-black/10 bg-white">
|
<div className="overflow-x-auto rounded-xl border border-black/10 bg-white">
|
||||||
<table className="min-w-[900px] w-full text-sm">
|
<table className="w-full min-w-[820px] text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-black/[0.03]">
|
<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) => (
|
{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}
|
{plan.name}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@ -1879,16 +1978,16 @@ export function LovartHome({
|
|||||||
<tr className="bg-black/[0.045]">
|
<tr className="bg-black/[0.045]">
|
||||||
<td
|
<td
|
||||||
colSpan={membershipPlans.length + 1}
|
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}
|
{category}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={`${category}-${row.model}`} className="border-t border-black/[0.06]">
|
<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) => (
|
{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.values[plan.id] !== "-" ? row.unit : ""}
|
{row.values[plan.id] && row.values[plan.id] !== "-" ? row.unit : ""}
|
||||||
</td>
|
</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,
|
"when": 1771150800000,
|
||||||
"tag": "0035_add_points_system",
|
"tag": "0035_add_points_system",
|
||||||
"breakpoints": true
|
"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 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", {
|
export const chat = pgTable("Chat", {
|
||||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||||
createdAt: timestamp("createdAt").notNull(),
|
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