diff --git a/README.md b/README.md index a3371a9..90bd891 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,46 @@ # NomadVIP | 异度星球 -VIP 会员解锁页,集成 PocketBase 支付校验、XorPay/ZPAY 支付。 +NomadVIP 前端(Next.js App Router),负责会员购买、支付跳转、支付状态轮询,以及与 `payjsapi` / PocketBase 联动完成 VIP 开通。 -## 服务配置(参考 desktop digital) +## 核心流程 -ZPAY、PocketBase、MinIO 配置与 `C:\project\digital` 保持一致,复制 `.env.example` 为 `.env.local` 后按需修改: +1. 前端调用 `app/api/pay` 发起支付。 +2. `app/api/pay` 按设备分流: + - 微信内:`xorpay` + - PC / 手机浏览器:`zpay` +3. 支付成功后通过 `payjsapi` 异步通知写入 PocketBase。 +4. 若通知延迟,前端会调用 `app/api/pay/confirm` 兜底写入 `payments` + `site_vip`。 + +## 关键配置 + +复制 `.env.example` 为 `.env.local`,按需修改: | 变量 | 说明 | 默认值 | |------|------|--------| -| `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` | Server Actions 加密密钥(可选,未设时自动从 `config/server-actions.key` 加载) | `config/server-actions.key` | -| `NEXT_PUBLIC_POCKETBASE_URL` | PocketBase 地址 | `https://pocketbase.hackrobot.cn` | -| `NEXT_PUBLIC_API_URL` / `PAYMENT_API_URL` | payjsapi 地址 | `https://api.hackrobot.cn` | -| `PAYMENT_PROVIDER` | 支付提供商(payjsapi 环境变量) | `xorpay` / `zpay` | +| `NEXT_PUBLIC_POCKETBASE_URL` | PocketBase 地址(浏览器侧) | `https://pocketbase.hackrobot.cn` | +| `POCKETBASE_URL` | PocketBase 地址(服务端) | `http://127.0.0.1:8090` | +| `PAYMENT_API_URL` / `NEXT_PUBLIC_API_URL` | payjsapi 公网地址 | `https://api.hackrobot.cn` | +| `PAYMENT_API_INTERNAL_URL` | 同机部署时 payjsapi 内网地址 | `http://127.0.0.1:8007` | +| `PAYMENT_DEFAULT_AMOUNT` | 默认支付金额(分) | `18800` | +| `ZPAY_SUBMIT_URL` | ZPAY 收银台地址 | `https://zpayz.cn/submit.php` | +| `POCKETBASE_EMAIL` / `POCKETBASE_PASSWORD` | confirm 兜底写库所需管理员账号 | - | -**XorPay**:直接调用 payh5,无 redirect 中转,表单提交至 xorpay 收银台。payjsapi 需设置 `PAYMENT_PROVIDER=xorpay`。切换渠道仅需前端改参数,后端 payjsapi 标准化接口。 - -**设备逻辑**:PC 显示二维码 + 支付状态轮询;手机/微信无中转页,直接跳转收银台。默认价格 8.8 元(880 分)。 -| `MINIO_ENDPOINT` | MinIO 端点 | `minioweb.hackrobot.cn` | -| `MINIO_BUCKET` | 存储桶 | `hackrobot` | - -详见 `config/services.config.ts`。 - -## Getting Started - -First, run the development server: +## 本地开发 ```bash +npm install npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +访问 `http://localhost:3000`。 -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## 自检与已修复问题(2026-04-01) -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +- 已修复:`app/api/pay/confirm` 在兜底写 `payments` 时使用默认金额,可能导致测试价/临时调价场景记账错误。 +- 修复后:优先使用支付网关返回的 `pay_price`(元)换算为分;只有拿不到实际金额时才回退默认金额。 -## Learn More +## 常见排查 -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- 支付发起失败:优先检查 `PAYMENT_API_INTERNAL_URL` / `PAYMENT_API_URL` 是否可达。 +- 已支付但未开通:检查 `payjsapi` 的 `/nomadvip/order_status` 与异步通知日志。 +- 账号已支付但 VIP 未识别:检查 `vip_email_link` 关联是否建立。 diff --git a/app/HomeClient.tsx b/app/HomeClient.tsx index cb3efbc..7d18f6c 100644 --- a/app/HomeClient.tsx +++ b/app/HomeClient.tsx @@ -1026,6 +1026,8 @@ export default function HomeClient() { onVerify={checkPaymentFromPB} onVerifyByEmail={checkVerifyByEmail} onVerifySuccess={handleVerifySuccess} + wechatNick={wechatNick} + wechatAvatar={wechatAvatar} /> )} diff --git a/app/api/pay/confirm/route.ts b/app/api/pay/confirm/route.ts index 8c0d97a..006212c 100644 --- a/app/api/pay/confirm/route.ts +++ b/app/api/pay/confirm/route.ts @@ -11,9 +11,18 @@ import { import { queryXorPayOrderStatus } from "@/app/lib/xorpayStatus"; import { queryZPayOrderStatus } from "@/app/lib/zpayStatus"; -const getTotalFee = () => +const getDefaultTotalFee = () => getPaymentConfig().defaultAmount ?? Number(process.env.PAYMENT_DEFAULT_AMOUNT) ?? 8800; +function parsePayPriceToCents(payPrice: unknown): number | null { + if (payPrice == null) return null; + const raw = String(payPrice).trim(); + if (!raw) return null; + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) return null; + return Math.round(value * 100); +} + async function getAdminToken(): Promise { const email = process.env.POCKETBASE_EMAIL; const password = process.env.POCKETBASE_PASSWORD; @@ -46,7 +55,7 @@ function resolvePayApiUrl(request: NextRequest): string { async function verifyOrderPaid( request: NextRequest, orderId: string -): Promise { +): Promise<{ paid: boolean; totalFeeCents: number | null }> { const apiUrl = resolvePayApiUrl(request); const url = `${apiUrl}/nomadvip/order_status?order_id=${encodeURIComponent(orderId)}`; console.log("[pay/confirm] verify order_status url=%s", url); @@ -59,11 +68,12 @@ async function verifyOrderPaid( }).finally(() => clearTimeout(timeout)); const data = await res.json().catch(() => ({})); const paid = !!data?.paid; + const totalFeeCents = parsePayPriceToCents(data?.pay_price); if (!paid && !res.ok) { console.warn("[pay/confirm] order_status non-ok:", res.status, url, data); } if (paid) { - return true; + return { paid: true, totalFeeCents }; } } catch (e) { console.error("[pay/confirm] verifyOrderPaid fetch error:", e); @@ -79,7 +89,10 @@ async function verifyOrderPaid( xorpay?.url || "" ); if (xorpay?.paid) { - return true; + return { + paid: true, + totalFeeCents: parsePayPriceToCents(xorpay?.pay_price), + }; } const zpay = await queryZPayOrderStatus(orderId, 5000); @@ -91,7 +104,10 @@ async function verifyOrderPaid( zpay?.error || "", zpay?.url || "" ); - return !!zpay?.paid; + return { + paid: !!zpay?.paid, + totalFeeCents: parsePayPriceToCents(zpay?.pay_price), + }; } async function createMemosUser(username: string, password: string): Promise { @@ -142,8 +158,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ ok: false, error: "缺少 order_id 或 user_id" }, { status: 400 }); } - const paid = await verifyOrderPaid(request, orderId); - if (!paid) { + const verifyResult = await verifyOrderPaid(request, orderId); + if (!verifyResult.paid) { console.error("[pay/confirm] 订单未支付 order_id=%s user_id=%s (请检查 payjsapi order_status 接口)", orderId, userId); return NextResponse.json({ ok: false, error: "订单未支付" }, { status: 400 }); } @@ -154,7 +170,7 @@ export async function POST(request: NextRequest) { } const pb = getPocketBaseConfig(); - const totalFee = getTotalFee(); + const totalFee = verifyResult.totalFeeCents ?? getDefaultTotalFee(); const checkExisting = await fetch( `${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(`order_id="${orderId}"`)}&perPage=1`, diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index 94edd6d..4e3f8a9 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -27,7 +27,7 @@ function buildErrorHtml(errorMsg: string): string { function buildPayHtml( payUrl: string, params: Record, - options?: { directToCashier?: boolean } + options?: { directToCashier?: boolean; displayName?: string; avatar?: string } ): string { const flat: Record = {}; for (const [k, v] of Object.entries(params)) { @@ -40,10 +40,13 @@ function buildPayHtml( .map(([k, v]) => ``) .join("\n"); const direct = options?.directToCashier; + const displayName = (options?.displayName || "当前用户").trim(); + const avatar = (options?.avatar || "").trim(); + const showPayerHint = !!avatar; // direct:微信内无中转,直接提交 | 非 direct:PC/H5 有中转页提示 return direct ? `支付
${inputs}
` - : `跳转支付

正在跳转到支付页面...

${inputs}
`; + : `跳转支付${showPayerHint ? `
${escapeAttr(displayName)}
开通对象:${escapeAttr(displayName)}
正在为你升级 VIP,马上进入支付...
` : ""}
${inputs}
`; } function setPayOrderCookie(res: NextResponse, orderId: string) { @@ -157,6 +160,8 @@ export async function POST(request: NextRequest) { const type = String(body?.type || "vip"); const channel = String(body?.channel || "wxpay"); const device = String(body?.device || "pc"); + const wechatNick = String(body?.wechat_nick || body?.nick || body?.user_name || "").trim(); + const wechatAvatar = String(body?.wechat_avatar || body?.avatar || "").trim(); // 渠道规则:pc/手机浏览器 -> zpay,仅微信内 -> xorpay const effectiveProvider = device === "wechat" ? "xorpay" : "zpay"; @@ -243,6 +248,8 @@ export async function POST(request: NextRequest) { const submitUrl = resData?.pay_url || payConfig.zpaySubmitUrl; const html = buildPayHtml(submitUrl, params || {}, { directToCashier: device === "wechat", + displayName: wechatNick || user_id, + avatar: wechatAvatar, }); const res = new NextResponse(html, { status: 200, @@ -255,6 +262,8 @@ export async function POST(request: NextRequest) { if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) { const userId = String(user_id || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); + const userNick = String(wechatNick || userId).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); + const userAvatar = String(wechatAvatar || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); const returnUrl = String(resData.return_url || finalReturnUrl).replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c @@ -293,6 +302,9 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;} .countdown-num{font-size:1rem;font-weight:700;color:#1e293b;line-height:1;} .countdown-unit{font-size:9px;color:#94a3b8;} .tip{font-size:13px;color:#64748b;margin-top:16px;} +.payer{display:flex;align-items:center;justify-content:center;gap:10px;margin:0 0 14px;} +.payer-avatar{width:30px;height:30px;border-radius:9999px;border:1px solid #e2e8f0;object-fit:cover;background:#f1f5f9;display:flex;align-items:center;justify-content:center;font-size:12px;} +.payer-text{font-size:12px;color:#475569;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .success-wrap{display:none;} .success-wrap.show{display:block;} .wait-wrap.hide{display:none;} @@ -309,6 +321,7 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}

${esc(title)}

${esc(hint)}

+ ${userAvatar ? `
${userNick}开通对象:${userNick} · 正在升级 VIP
` : ""}
@@ -473,6 +486,8 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;} const orderId = String(params?.order_id || params?.out_trade_no || "").trim(); const html = buildPayHtml(submitUrl, params as Record, { directToCashier: device === "wechat" || device === "h5", + displayName: wechatNick || user_id, + avatar: wechatAvatar, }); const res = new NextResponse(html, { status: 200, diff --git a/app/lib/triggerPay.ts b/app/lib/triggerPay.ts index d18cdd6..fbb23aa 100644 --- a/app/lib/triggerPay.ts +++ b/app/lib/triggerPay.ts @@ -14,6 +14,7 @@ const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30; const PAY_ORDER_COOKIE = "nomadvip_pay_order"; const PAY_ORDER_MAX_AGE = 60 * 15; const VIP_PAY_TEST_MODE_KEY = "vipPayTestMode"; +const PAY_LAUNCH_OVERLAY_ID = "nomadvip-pay-launch-overlay"; function getStorage(key: string): string | null { if (typeof window === "undefined") return null; @@ -50,6 +51,41 @@ function setPayOrderCookie(orderId: string): void { )}; Path=/; Max-Age=${PAY_ORDER_MAX_AGE}; SameSite=Lax${secure}`; } +function getDisplayProfile() { + const wechatNick = (getStorage("wechatNick") || "").trim(); + const userName = (getStorage("userName") || "").trim(); + const userEmail = (getStorage("userEmail") || "").trim(); + const wechatAvatar = (getStorage("wechatAvatar") || "").trim(); + const displayName = wechatNick || userName || userEmail || "当前用户"; + return { displayName, avatar: wechatAvatar || "" }; +} + +function showPayLaunchOverlay(displayName: string, avatar?: string): void { + if (typeof document === "undefined") return; + if (!(avatar || "").trim()) return; + const prev = document.getElementById(PAY_LAUNCH_OVERLAY_ID); + if (prev) prev.remove(); + const safeName = displayName.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); + const safeAvatar = (avatar || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); + const node = document.createElement("div"); + node.id = PAY_LAUNCH_OVERLAY_ID; + node.style.cssText = "position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(15,23,42,.28);pointer-events:none;"; + node.innerHTML = ` +
+ ${safeName} +
+
开通对象:${safeName}
+
正在为你升级 VIP,马上进入支付...
+
+
+ `; + document.body.appendChild(node); + window.setTimeout(() => { + const current = document.getElementById(PAY_LAUNCH_OVERLAY_ID); + if (current) current.remove(); + }, 2400); +} + function savePendingOrder(orderId: string): void { const normalizedOrderId = orderId.trim(); if (!normalizedOrderId) return; @@ -118,6 +154,8 @@ async function getOrCreateUserId(): Promise { function redirectToPayZpay(params: { user_id: string; wechat_userid?: string; + wechat_nick?: string; + wechat_avatar?: string; return_url: string; total_fee: number; type: string; @@ -132,6 +170,8 @@ function redirectToPayZpay(params: { const fields: [string, string][] = [ ["user_id", params.user_id], ...(params.wechat_userid ? [["wechat_userid", params.wechat_userid] as [string, string]] : []), + ...(params.wechat_nick ? [["wechat_nick", params.wechat_nick] as [string, string]] : []), + ...(params.wechat_avatar ? [["wechat_avatar", params.wechat_avatar] as [string, string]] : []), ["return_url", params.return_url], ["total_fee", String(params.total_fee)], ["type", params.type], @@ -155,6 +195,8 @@ async function redirectToPayZpayH5( params: { user_id: string; wechat_userid?: string; + wechat_nick?: string; + wechat_avatar?: string; return_url: string; total_fee: number; type: string; @@ -215,6 +257,8 @@ async function redirectToPayZpayH5( function payWechatXorpaySync(params: { user_id: string; wechat_userid?: string; + wechat_nick?: string; + wechat_avatar?: string; return_url: string; total_fee: number; type: string; @@ -228,6 +272,8 @@ function payWechatXorpaySync(params: { const fields: [string, string][] = [ ["user_id", params.user_id], ...(params.wechat_userid ? [["wechat_userid", params.wechat_userid] as [string, string]] : []), + ...(params.wechat_nick ? [["wechat_nick", params.wechat_nick] as [string, string]] : []), + ...(params.wechat_avatar ? [["wechat_avatar", params.wechat_avatar] as [string, string]] : []), ["return_url", params.return_url], ["total_fee", String(params.total_fee)], ["type", params.type], @@ -271,9 +317,17 @@ export async function triggerPay( (getStorage("userid") || "").trim(); const isPayTestMode = getStorage(VIP_PAY_TEST_MODE_KEY) === "1"; const totalFee = isPayTestMode ? 100 : getPaymentConfig().defaultAmount ?? 8800; + const profile = getDisplayProfile(); + const shouldShowLaunchOverlay = + device !== "wechat" && !!(profile.avatar || "").trim(); + if (shouldShowLaunchOverlay) { + showPayLaunchOverlay(profile.displayName, profile.avatar); + } const base = { user_id: userId, wechat_userid: wechatUserId || undefined, + wechat_nick: profile.displayName, + wechat_avatar: profile.avatar || undefined, return_url: returnUrl, total_fee: totalFee, type: "vip", diff --git a/app/vip/components/LandingPage.tsx b/app/vip/components/LandingPage.tsx index 28e0425..1617c2e 100644 --- a/app/vip/components/LandingPage.tsx +++ b/app/vip/components/LandingPage.tsx @@ -21,6 +21,8 @@ type LandingPageProps = { user_id?: string ) => Promise; onVerifySuccess: () => void; + wechatNick?: string | null; + wechatAvatar?: string | null; }; export function LandingPage({ @@ -28,6 +30,8 @@ export function LandingPage({ onVerify, onVerifyByEmail, onVerifySuccess, + wechatNick, + wechatAvatar, }: LandingPageProps) { const [loginModalOpen, setLoginModalOpen] = useState(false); const [promoModalOpen, setPromoModalOpen] = useState(false); @@ -71,6 +75,8 @@ export function LandingPage({ onJoin={onJoin} onLogin={() => setLoginModalOpen(true)} payTestMode={payTestMode} + wechatNick={wechatNick} + wechatAvatar={wechatAvatar} />
diff --git a/app/vip/components/VipHeader.tsx b/app/vip/components/VipHeader.tsx index be20f3d..585491f 100644 --- a/app/vip/components/VipHeader.tsx +++ b/app/vip/components/VipHeader.tsx @@ -98,7 +98,9 @@ export function VipHeader({
)} - {displayName} + + {displayName} + ) : null} diff --git a/app/vip/components/landing/LandingCTA.tsx b/app/vip/components/landing/LandingCTA.tsx index ace27ff..a54edee 100644 --- a/app/vip/components/landing/LandingCTA.tsx +++ b/app/vip/components/landing/LandingCTA.tsx @@ -8,13 +8,23 @@ type LandingCTAProps = { onJoin: () => void; onLogin: () => void; payTestMode?: boolean; + wechatAvatar?: string | null; + wechatNick?: string | null; }; /** 设为 true 可重新展示「适合你 / 请止步」区块(下方 JSX 与 vip.module.css 内样式均保留) */ const SHOW_CTA_AUDIENCE_BOUNDARY = false; -export function LandingCTA({ onJoin, onLogin, payTestMode }: LandingCTAProps) { +export function LandingCTA({ + onJoin, + onLogin, + payTestMode, + wechatAvatar, + wechatNick, +}: LandingCTAProps) { const [restoreDate] = useState(() => formatRestoreDateShort()); + const hasAvatar = !!(wechatAvatar || "").trim(); + const badgeName = (wechatNick || "微信用户").trim(); return (
@@ -75,6 +85,62 @@ export function LandingCTA({ onJoin, onLogin, payTestMode }: LandingCTAProps) { style={payTestMode ? { background: "linear-gradient(135deg,#16a34a,#22c55e)" } : undefined} > 🔒 立即加入 + {hasAvatar ? ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {badgeName} + + VIP + + + ) : null}