接入DuluPay充值闭环并统一升级弹窗比例
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-14 15:25:36 +08:00
parent 83d2a1574b
commit 6cac41f1c0
12 changed files with 1150 additions and 66 deletions

View File

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

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

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

View File

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

View File

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

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

View File

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

View 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");

View File

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

View File

@ -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(),

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