From b2a50514a4e2ba27da21472755d4c1b96e5532e1 Mon Sep 17 00:00:00 2001 From: JetSprow Date: Fri, 1 May 2026 03:50:05 +1000 Subject: [PATCH] fix: route wallet card redemption through api --- .../wallet/_components/wallet-actions.tsx | 30 ++++++++++- src/app/api/wallet/redeem-card/route.ts | 50 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/app/api/wallet/redeem-card/route.ts diff --git a/src/app/(user)/wallet/_components/wallet-actions.tsx b/src/app/(user)/wallet/_components/wallet-actions.tsx index e19dcdc..4dfd8d6 100644 --- a/src/app/(user)/wallet/_components/wallet-actions.tsx +++ b/src/app/(user)/wallet/_components/wallet-actions.tsx @@ -4,7 +4,7 @@ import { useTransition } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { CreditCard, Gift } from "lucide-react"; -import { createWalletRecharge, redeemWalletCard } from "@/actions/user/wallet"; +import { createWalletRecharge } from "@/actions/user/wallet"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -15,6 +15,32 @@ function money(value: number) { return `¥${value.toFixed(2)}`; } +type RedeemCardResult = + | { + type: "BALANCE"; + amount: number; + balanceAfter: number; + } + | { + type: "PLAN"; + planName: string; + }; + +async function redeemWalletCardByApi(formData: FormData): Promise { + const response = await fetch("/api/wallet/redeem-card", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: String(formData.get("code") ?? "") }), + }); + const payload = await response.json().catch(() => null) as { error?: string } | RedeemCardResult | null; + + if (!response.ok) { + throw new Error(payload && "error" in payload && payload.error ? payload.error : "充值卡兑换失败"); + } + + return payload as RedeemCardResult; +} + export function WalletActions() { const router = useRouter(); const [rechargePending, startRecharge] = useTransition(); @@ -64,7 +90,7 @@ export function WalletActions() { const formData = new FormData(form); startRedeem(async () => { try { - const result = await redeemWalletCard(formData); + const result = await redeemWalletCardByApi(formData); form.reset(); if (result.type === "BALANCE") { window.dispatchEvent(new CustomEvent(WALLET_BALANCE_UPDATED_EVENT, { diff --git a/src/app/api/wallet/redeem-card/route.ts b/src/app/api/wallet/redeem-card/route.ts new file mode 100644 index 0000000..ab766e2 --- /dev/null +++ b/src/app/api/wallet/redeem-card/route.ts @@ -0,0 +1,50 @@ +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { jsonError, jsonOk } from "@/lib/api-response"; +import { rateLimit } from "@/lib/rate-limit"; +import { getActiveSession } from "@/lib/require-auth"; +import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review"; +import { redeemRechargeCard } from "@/services/wallet"; + +const redeemCardSchema = z.object({ + code: z.string().trim().min(4, "请输入充值卡卡密"), +}); + +export async function POST(req: Request) { + try { + const session = await getActiveSession(); + if (!session) { + return jsonError("未登录", { status: 401 }); + } + + if (session.user.role !== "ADMIN") { + const restriction = await getActiveSubscriptionRiskRestriction(session.user.id); + if (restriction) { + return jsonError("账户存在未处理的订阅风控限制,请先新建工单联系客服", { status: 403 }); + } + } + + const { success, remaining } = await rateLimit( + `ratelimit:redeem-card:${session.user.id}`, + 10, + 60, + ); + if (!success) { + return jsonError("兑换请求过于频繁,请稍后再试", { + status: 429, + headers: { "X-RateLimit-Remaining": String(remaining) }, + }); + } + + const payload = redeemCardSchema.parse(await req.json()); + const result = await redeemRechargeCard(session.user.id, payload.code); + + revalidatePath("/wallet"); + revalidatePath("/subscriptions"); + revalidatePath("/dashboard"); + + return jsonOk(result); + } catch (error) { + return jsonError(error, { fallback: "充值卡兑换失败" }); + } +}