From a5667bbbb27b54de58fd2e34e2300db2e0803814 Mon Sep 17 00:00:00 2001 From: mizorewww Date: Tue, 6 Oct 2026 16:00:12 +0800 Subject: [PATCH] fix: verify public checkout links and add a paced queue with Material status cards --- frontend/scripts/preview.mjs | 19 ++ frontend/src/ManualPaymentPanel.tsx | 72 ++++++-- frontend/src/PaymentQueueCard.tsx | 77 ++++++++ internal/checkout/creation_limit.go | 40 +++++ internal/checkout/creation_limit_test.go | 32 ++++ internal/checkout/public_link.go | 109 +++++++++-- internal/checkout/public_link_test.go | 89 ++++++++- internal/checkout/xapi.go | 4 + internal/site/assets/admin.html | 2 +- internal/site/assets/admin.js | 108 +++++------ internal/site/assets/admin.js.gz | Bin 199957 -> 205593 bytes internal/site/assets/app.js | 86 ++++----- internal/site/assets/app.js.gz | Bin 164603 -> 166904 bytes internal/site/assets/index.html | 2 +- internal/site/manual_link.go | 33 +++- internal/site/public_link_queue.go | 220 +++++++++++++++++++++++ internal/site/public_link_queue_test.go | 117 ++++++++++++ internal/site/site.go | 4 + 18 files changed, 881 insertions(+), 133 deletions(-) create mode 100644 frontend/src/PaymentQueueCard.tsx create mode 100644 internal/checkout/creation_limit.go create mode 100644 internal/checkout/creation_limit_test.go create mode 100644 internal/site/public_link_queue.go create mode 100644 internal/site/public_link_queue_test.go diff --git a/frontend/scripts/preview.mjs b/frontend/scripts/preview.mjs index b146edf..1674733 100644 --- a/frontend/scripts/preview.mjs +++ b/frontend/scripts/preview.mjs @@ -8,6 +8,7 @@ const port = Number(process.env.PREVIEW_PORT || 4173); const paused = process.env.PREVIEW_PAUSED === "true"; const states = new Map(); const attempts = new Map(); +const linkJobs = new Map(); let folders = [ { id: "a".repeat(32), name: "本地预览 · 示例批次" }, { id: "b".repeat(32), name: "国庆活动" }, @@ -84,6 +85,19 @@ createServer(async (req, res) => { json(200, { plans: [{months: 3, amount: 30000, currency: "BDT"}, {months: 6, amount: 60000, currency: "BDT"}] }); return; } + if (req.method === "GET" && url.pathname.startsWith("/api/manual-link/queue/")) { + const ticket = url.pathname.split("/").pop(); + const job = linkJobs.get(ticket); + if (!job) { json(404, {message: "排队记录已失效,请重新提交。"}); return; } + job.polls++; + if (job.polls < 3) { + json(202, {ticket, status: job.polls === 1 ? "queued" : "processing", position: 1, ahead: 0, estimated_wait_seconds: job.polls === 1 ? 20 : 10, message: job.polls === 1 ? "前方还有 0 人,预计约 20 秒后生成链接。请保持页面打开。" : "正在生成付款链接,预计还需约 10 秒。"}); return; + } + if (job.username === "expired_demo" && !job.verified_unpaid) { + json(409, {message: "原付款链接已失效,请核实原订单未付款后再重新生成。", needs_unpaid_verification: true}); return; + } + json(200, {username: job.username, months: job.months, amount: job.months === 3 ? 30000 : 60000, currency: "BDT", status: "created", checkout_url: `https://checkout.stripe.com/c/pay/cs_test_PreviewOnly${ticket}`}); return; + } if (req.method === "GET" && url.pathname === "/healthz") { json(200, { ok: true, payments_enabled: !paused }); return; @@ -253,6 +267,11 @@ createServer(async (req, res) => { if (![3, 6].includes(body.months) || !/^[a-z0-9_]{1,15}$/.test(body.username || "")) { json(400, {message: "请填写正确用户名和套餐。"}); return; } + if (url.pathname === "/api/manual-link") { + const ticket = randomBytes(12).toString("hex"); + linkJobs.set(ticket, {...body, polls: 0}); + json(202, {ticket, status: "queued", position: 2, ahead: 1, estimated_wait_seconds: 40, message: "前方还有 1 人,预计约 40 秒后生成链接。请保持页面打开。"}); return; + } if (body.username === "expired_demo" && !body.verified_unpaid) { json(409, {message: "原付款链接已失效,请核实原订单未付款后再重新生成。", needs_unpaid_verification: true}); return; } diff --git a/frontend/src/ManualPaymentPanel.tsx b/frontend/src/ManualPaymentPanel.tsx index efcfce9..1bc43f3 100644 --- a/frontend/src/ManualPaymentPanel.tsx +++ b/frontend/src/ManualPaymentPanel.tsx @@ -1,13 +1,15 @@ import { useEffect, useRef, useState } from "react"; -import { Alert, Box, Button, Checkbox, FormControlLabel, LinearProgress, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material"; +import { Alert, Box, Button, Card, CardContent, Checkbox, Divider, FormControlLabel, LinearProgress, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material"; import LinkRounded from "@mui/icons-material/LinkRounded"; import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined"; +import CheckCircleOutlineRounded from "@mui/icons-material/CheckCircleOutlineRounded"; +import { PaymentQueueCard, type QueueProgress } from "./PaymentQueueCard"; import OpenInNewRounded from "@mui/icons-material/OpenInNewRounded"; import { adminApi } from "./adminApi"; import { request } from "./shared"; type Plan = { months: number; amount: number; currency: string }; -type Result = Plan & { username: string; status: string; checkout_url?: string; message?: string; needs_unpaid_verification?: boolean }; +type Result = Plan & { username: string; status: string; checkout_url?: string; message?: string; needs_unpaid_verification?: boolean; ticket?: string; position?: number; ahead?: number; estimated_wait_seconds?: number }; function price(p: Plan) { return `${p.currency} ${(p.amount / 100).toFixed(2)}`; } export function ManualPaymentPanel({ publicMode = false }: { publicMode?: boolean }) { const endpoint = publicMode ? "/api/manual-link" : "/api/admin/manual-link"; @@ -21,6 +23,10 @@ export function ManualPaymentPanel({ publicMode = false }: { publicMode?: boolea const [needsVerification, setNeedsVerification] = useState(false); const [verified, setVerified] = useState(false); const [notice, setNotice] = useState(""); + const [queueProgress, setQueueProgress] = useState({ status: "submitting" }); + const resultHeading = useRef(null); + const usernameInput = useRef(null); + const activeRequest = useRef(null); const inFlight = useRef(false); const cleanUser = username.trim().replace(/^@/, "").toLowerCase(); const valid = /^[a-z0-9_]{1,15}$/.test(cleanUser); @@ -34,16 +40,27 @@ export function ManualPaymentPanel({ publicMode = false }: { publicMode?: boolea } catch (e) { setPlanError((e as Error).message); } } useEffect(() => { void loadPlans(); }, []); + useEffect(() => () => { activeRequest.current?.abort(); }, []); + useEffect(() => { if (result && publicMode) resultHeading.current?.focus({ preventScroll: true }); }, [result, publicMode]); + useEffect(() => { if (!busy && error) usernameInput.current?.focus({ preventScroll: true }); }, [busy, error]); function reset() { setResult(null); setError(""); setNotice(""); setNeedsVerification(false); setVerified(false); } async function generate() { if (inFlight.current || !valid || !plans.some((p) => p.months === months)) return; - inFlight.current = true; setBusy(true); setError(""); setNotice(""); setResult(null); + inFlight.current = true; setBusy(true); setError(""); setNotice(""); setResult(null); setQueueProgress({ status: "submitting" }); + const controller = new AbortController(); + activeRequest.current = controller; try { - const { ok, data } = await request(endpoint, { username: cleanUser, months, verified_unpaid: needsVerification && verified }, AbortSignal.timeout(120000)); + let { ok, data } = await request(endpoint, { username: cleanUser, months, verified_unpaid: needsVerification && verified }, AbortSignal.any([controller.signal, AbortSignal.timeout(120000)])); + while (ok && data.ticket && (data.status === "queued" || data.status === "processing")) { + setQueueProgress({ status: data.status as "queued" | "processing", ahead: data.ahead ?? Math.max(0, (data.position ?? 1) - 1), estimated_wait_seconds: data.estimated_wait_seconds }); + await new Promise((resolve) => setTimeout(resolve, 3000)); + controller.signal.throwIfAborted(); + ({ ok, data } = await request(`${endpoint}/queue/${encodeURIComponent(data.ticket)}`, undefined, AbortSignal.any([controller.signal, AbortSignal.timeout(45000)]))); + } if (!ok) { setNeedsVerification(Boolean(data.needs_unpaid_verification)); setError(data.message || "生成失败,请稍后重试。"); return; } setNeedsVerification(false); setVerified(false); setResult(data); } catch (e) { setError((e as Error).name === "TimeoutError" ? "请求超时,请使用同一用户名和套餐重试,系统会检查已有订单。" : (e as Error).message); } - finally { inFlight.current = false; setBusy(false); } + finally { inFlight.current = false; setBusy(false); setQueueProgress({ status: "submitting" }); activeRequest.current = null; } } async function copy() { if (!result?.checkout_url) return; @@ -56,27 +73,56 @@ export function ManualPaymentPanel({ publicMode = false }: { publicMode?: boolea