's'
This commit is contained in:
227
app/api/pay/route.ts
Normal file
227
app/api/pay/route.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* nomadvip 支付 API - 参考 digital
|
||||
* PC: 二维码 | H5: 302 跳转 | 微信内: 收银台表单
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPaymentConfig, getApiUrlFromRequest } from "@/config/services.config";
|
||||
|
||||
const ZPAY_REQUIRED = ["pid", "type", "out_trade_no", "notify_url", "return_url", "name", "money", "sign", "sign_type"];
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function buildPayHtml(payUrl: string, params: Record<string, unknown>): string {
|
||||
const flat: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v == null) continue;
|
||||
const s = String(v).trim();
|
||||
if (s === "") continue;
|
||||
flat[k] = s;
|
||||
}
|
||||
const inputs = Object.entries(flat)
|
||||
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
|
||||
.join("\n");
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>跳转支付...</title>
|
||||
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>正在跳转到支付页面...</p>
|
||||
<form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">
|
||||
${inputs}
|
||||
</form>
|
||||
<script>document.getElementById("zpayForm").submit();</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function setPayOrderCookie(res: NextResponse, orderId: string) {
|
||||
res.cookies.set("nomadvip_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
let body: Record<string, string | number>;
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
body = (await request.json()) as Record<string, string | number>;
|
||||
} else {
|
||||
const form = await request.formData();
|
||||
body = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
}
|
||||
|
||||
const payConfig = getPaymentConfig();
|
||||
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
const envApiUrl = process.env.PAYMENT_API_URL || process.env.NEXT_PUBLIC_API_URL;
|
||||
const apiUrl = (envApiUrl ? payConfig.apiUrl : getApiUrlFromRequest(hostHeader)).replace(/\/$/, "");
|
||||
const user_id = String(body?.user_id || "").trim();
|
||||
const return_url = String(body?.return_url || "").trim();
|
||||
const total_fee = Number(body?.total_fee) || 18800;
|
||||
const type = String(body?.type || "vip");
|
||||
const channel = String(body?.channel || "alipay");
|
||||
const device = String(body?.device || "pc");
|
||||
|
||||
if (!user_id) {
|
||||
return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const origin =
|
||||
request.headers.get("x-forwarded-host")
|
||||
? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
|
||||
: request.headers.get("origin") || "http://localhost:3000";
|
||||
const finalReturnUrl = return_url || `${origin}/paid`;
|
||||
|
||||
const wantHtml = String(body?.html) === "1";
|
||||
|
||||
if (!wantHtml) {
|
||||
return NextResponse.json({ ok: false, error: "请传 html=1" }, { status: 400 });
|
||||
}
|
||||
|
||||
const clientIp =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"";
|
||||
|
||||
const redirectPayload = {
|
||||
user_id,
|
||||
total_fee,
|
||||
type,
|
||||
channel,
|
||||
return_url: finalReturnUrl,
|
||||
device,
|
||||
...(clientIp && { client_ip: clientIp }),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
const redirectRes = await fetch(`${apiUrl}/nomadvip/payh5/redirect`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(redirectPayload),
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||||
const location = redirectRes.headers.get("location");
|
||||
if (location) {
|
||||
const res = NextResponse.redirect(location);
|
||||
const orderId = redirectRes.headers.get("x-pay-order-id")?.trim();
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
const resData = await redirectRes.json().catch(() => ({}));
|
||||
|
||||
if (redirectRes.status === 200 && resData?.use_form) {
|
||||
// 微信内:直接用 payh5 获取 params,构建收银台表单(不调用 redirect 避免重复创建订单)
|
||||
const createRes = await fetch(`${apiUrl}/nomadvip/payh5`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id, total_fee, type, channel }),
|
||||
});
|
||||
const createData = await createRes.json().catch(() => ({}));
|
||||
const params = createData?.params || {};
|
||||
const orderId = String(params?.out_trade_no || "").trim();
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
|
||||
}
|
||||
const html = buildPayHtml(payConfig.zpaySubmitUrl, params as Record<string, unknown>);
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
return res;
|
||||
}
|
||||
|
||||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||
const returnUrl = String(resData.return_url || finalReturnUrl).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const imgSrc = String(resData.img).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const orderId = String(resData.order_id || "").replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const ch = String(resData.channel || "alipay").toLowerCase();
|
||||
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
|
||||
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
|
||||
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
|
||||
const esc = (s: string) =>
|
||||
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${esc(title)}</title>
|
||||
<style>body{font-family:system-ui;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;}h2{color:#333;margin-bottom:8px;}p{color:#64748b;font-size:14px;}img{width:220px;height:220px;margin:20px 0;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}.tip{font-size:12px;color:#94a3b8;margin-top:8px;}</style></head>
|
||||
<body>
|
||||
<h2>${esc(title)}</h2>
|
||||
<p>${esc(hint)}</p>
|
||||
<img src="${imgSrc}" alt="支付二维码" />
|
||||
<p class="tip" id="tip">支付成功后将自动跳转...</p>
|
||||
<script>
|
||||
(function(){
|
||||
var orderId="${orderId}";
|
||||
var returnUrl="${returnUrl}";
|
||||
if(!orderId){return;}
|
||||
var tip=document.getElementById("tip");
|
||||
var t=0;
|
||||
var maxT=300;
|
||||
function check(){
|
||||
t++;
|
||||
if(t>maxT){tip.textContent="轮询超时";return;}
|
||||
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d.ok&&d.paid){window.location.href=returnUrl;return;}
|
||||
setTimeout(check,2000);
|
||||
})
|
||||
.catch(function(){setTimeout(check,2000);});
|
||||
}
|
||||
setTimeout(check,2000);
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
return res;
|
||||
}
|
||||
|
||||
if (redirectRes.status === 400 || redirectRes.status === 500) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: resData?.detail || resData?.msg || "支付跳转失败" },
|
||||
{ status: redirectRes.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: false, error: "支付创建失败" }, { status: 500 });
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const msg = err.message || "";
|
||||
const hint =
|
||||
msg.includes("fetch") || msg.includes("ECONNREFUSED")
|
||||
? "请确认 payjsapi 已启动"
|
||||
: err.name === "AbortError"
|
||||
? "请求超时"
|
||||
: msg;
|
||||
console.error("Pay API error:", err);
|
||||
return NextResponse.json({ ok: false, error: `支付请求失败: ${hint}` }, { status: 500 });
|
||||
}
|
||||
}
|
||||
23
app/api/pay/status/route.ts
Normal file
23
app/api/pay/status/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPaymentConfig, getApiUrlFromRequest } from "@/config/services.config";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const orderId = request.nextUrl.searchParams.get("order_id");
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
|
||||
}
|
||||
const payConfig = getPaymentConfig();
|
||||
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
const envApiUrl = process.env.PAYMENT_API_URL || process.env.NEXT_PUBLIC_API_URL;
|
||||
const apiUrl = (envApiUrl ? payConfig.apiUrl : getApiUrlFromRequest(hostHeader)).replace(/\/$/, "");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${apiUrl}/nomadvip/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json({ ok: true, paid: !!data?.paid });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ ok: false, paid: false, error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
28
app/globals.css
Normal file
28
app/globals.css
Normal file
@@ -0,0 +1,28 @@
|
||||
@import "tailwindcss";
|
||||
@import "github-markdown-css/github-markdown.css";
|
||||
@import "animate.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
50
app/index-content.ts
Normal file
50
app/index-content.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export const indexMd = `
|
||||
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/image_20251231_015321_20586.jpg" alt="image_20251231_015321_20586.jpg" width="271">
|
||||
|
||||
🌟你将收获:
|
||||
🚀 业务方向:无人直播、RPA 自动化、web开发
|
||||
|
||||
⚙️ 技术杠杆:云手机、PVE 虚拟化、服务器集群
|
||||
|
||||
🌐 海外平台:YouTube、TikTok、SaaS 运营实战
|
||||
|
||||
|
||||
|
||||
<br/>
|
||||
|
||||
📖 解锁=>电子书+视频教程
|
||||
|
||||
1️⃣[《数字游民》:地理套利 + 自动化杠杆](https://hackrobot.cn)
|
||||
|
||||
2️⃣ [《云手机》:低成本工作室搭建指南](https://android.hackrobot.cn)
|
||||
|
||||
<img src="https://minioweb.hackrobot.cn/hackrobot/20260101205143_20260101_205202_1552.png" title="" alt="20260101205143_20260101_205202_1552.png" width="180">
|
||||
|
||||
|
||||
|
||||
📱设备自助下单(开箱即用)
|
||||
|
||||
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/wqdq_20260101_212749_8560.jpg" alt="wqdq_20260101_212749_8560.jpg" width="282">
|
||||
|
||||
|
||||
|
||||
📚 即时问答 (技术咨询)
|
||||
|
||||
<img src="https://minioweb.hackrobot.cn/hackrobot/20260101203904_20260101_204741_699.png" title="" alt="20260101203904_20260101_204741_699.png" width="284">
|
||||
|
||||
㊙️私密社群
|
||||
|
||||
<img src="https://minioweb.hackrobot.cn/hackrobot/20260101204456_20260101_204802_768.png" title="" alt="20260101204456_20260101_204802_768.png" width="186">
|
||||
|
||||
<br/>
|
||||
|
||||
🚫 内容不适合想躺赚、现金流紧张的人
|
||||
|
||||
💰 价格随参与人数阶梯式调整
|
||||
(维护与答疑成本随规模增长)
|
||||
|
||||
📌 本内容为虚拟数字交付
|
||||
不支持退款
|
||||
|
||||
👉 **限时特惠,3月12日恢复**
|
||||
`;
|
||||
231
app/index.module.css
Normal file
231
app/index.module.css
Normal file
@@ -0,0 +1,231 @@
|
||||
/* markdown-body from github-markdown-css */
|
||||
.markVip {
|
||||
padding-left: 50px;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.markVip img {
|
||||
display: block;
|
||||
margin: 10px auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.markVip p,
|
||||
.markVip li,
|
||||
.markVip h1,
|
||||
.markVip h2,
|
||||
.markVip h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.index {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.indexBook {
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.indexBookLeft {
|
||||
min-width: 300px;
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.leftTitle {
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
color: #69b1ff;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cardItem {
|
||||
padding: 10px;
|
||||
width: 250px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: #f0f5ff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.cardItemLeft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.cardItemLeftImg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.cardItemRight {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cardItemRightUp {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.cardItemRightDown {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-size: 15px;
|
||||
flex-wrap: wrap;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.leftBtn {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.startRead {
|
||||
width: 250px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #69b1ff 0%, #4096ff 100%);
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.startRead:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.indexBookImg {
|
||||
margin-top: 15px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.bookImg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.svgItems {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* H5 下隐藏「解锁vip」按钮下方模块(无人直播、RPA自动化、聊天机器人) */
|
||||
@media (max-width: 768px) {
|
||||
.svgItems {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.svgItem {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.bookImgItem {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bookImgText {
|
||||
margin-top: 20px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.vipcode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.vipcode button {
|
||||
margin: 8px 0;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.index {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.indexBook {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #f0f5ff;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.indexBookLeft .card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.indexBookLeft .leftBtn .startRead {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.indexBookImg .bookImg {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.svgItems {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin-top: 80px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
34
app/layout.tsx
Normal file
34
app/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "异度星球",
|
||||
description: "数字游民社群",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
38
app/lib/payEnv.ts
Normal file
38
app/lib/payEnv.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 支付环境检测:PC / H5 / 微信浏览器
|
||||
* 参考 digital app/lib/payEnv.ts
|
||||
*/
|
||||
|
||||
export type PayEnv = "pc" | "h5" | "wechat";
|
||||
export type PayChannel = "alipay" | "wxpay";
|
||||
|
||||
export function isWeChat(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /MicroMessenger/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export function isMobile(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /AppleWebKit.*Mobile|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
}
|
||||
|
||||
export function getPayEnv(): PayEnv {
|
||||
if (isWeChat()) return "wechat";
|
||||
if (isMobile()) return "h5";
|
||||
return "pc";
|
||||
}
|
||||
|
||||
export function getRecommendedChannel(env: PayEnv): PayChannel {
|
||||
return "wxpay";
|
||||
}
|
||||
|
||||
export function mustUseWxPay(env: PayEnv): boolean {
|
||||
return env === "wechat";
|
||||
}
|
||||
|
||||
export function getDeviceFromEnv(env: PayEnv): "pc" | "h5" | "wechat" {
|
||||
if (env === "wechat") return "wechat";
|
||||
return env === "pc" ? "pc" : "h5";
|
||||
}
|
||||
75
app/lib/usePayStatusPoll.ts
Normal file
75
app/lib/usePayStatusPoll.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 微信 H5 支付完成后可能不跳转,通过 cookie 轮询检测支付状态
|
||||
* 参考 digital usePayStatusPoll
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const COOKIE_NAME = "nomadvip_pay_order";
|
||||
const POLL_INTERVAL = 2000;
|
||||
const MAX_POLLS = 150;
|
||||
const MAX_AGE_MS = 15 * 60 * 1000;
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function clearCookie(name: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
document.cookie = `${name}=; path=/; max-age=0`;
|
||||
}
|
||||
|
||||
function parsePayOrderCookie(raw: string | null): [string | null, boolean] {
|
||||
if (!raw?.trim()) return [null, false];
|
||||
const parts = raw.split("|");
|
||||
const orderId = parts[0]?.trim() || raw.trim();
|
||||
const ts = parts[1] ? parseInt(parts[1], 10) : NaN;
|
||||
if (!orderId) return [null, false];
|
||||
if (Number.isNaN(ts)) return [orderId, false];
|
||||
if (Date.now() - ts > MAX_AGE_MS) return [orderId, false];
|
||||
return [orderId, true];
|
||||
}
|
||||
|
||||
export function usePayStatusPoll(onPaid: () => void): boolean {
|
||||
const [polling, setPolling] = useState(false);
|
||||
const onPaidRef = useRef(onPaid);
|
||||
onPaidRef.current = onPaid;
|
||||
|
||||
useEffect(() => {
|
||||
const raw = getCookie(COOKIE_NAME);
|
||||
const [orderId, isValid] = parsePayOrderCookie(raw);
|
||||
if (!orderId || !isValid) {
|
||||
if (raw) clearCookie(COOKIE_NAME);
|
||||
return;
|
||||
}
|
||||
|
||||
setPolling(true);
|
||||
let count = 0;
|
||||
const timer = setInterval(async () => {
|
||||
count++;
|
||||
if (count > MAX_POLLS) {
|
||||
clearInterval(timer);
|
||||
clearCookie(COOKIE_NAME);
|
||||
setPolling(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/pay/status?order_id=${encodeURIComponent(orderId)}`);
|
||||
const data = await res.json();
|
||||
if (data?.ok && data?.paid) {
|
||||
clearInterval(timer);
|
||||
clearCookie(COOKIE_NAME);
|
||||
setPolling(false);
|
||||
onPaidRef.current();
|
||||
}
|
||||
} catch {
|
||||
// 继续轮询
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return polling;
|
||||
}
|
||||
310
app/page.tsx
Normal file
310
app/page.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { marked } from "marked";
|
||||
import PocketBase from "pocketbase";
|
||||
import { indexMd } from "./index-content";
|
||||
import styles from "./index.module.css";
|
||||
import {
|
||||
getPayEnv,
|
||||
getRecommendedChannel,
|
||||
mustUseWxPay,
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/lib/payEnv";
|
||||
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
|
||||
|
||||
const PB_URL =
|
||||
process.env.NEXT_PUBLIC_POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
|
||||
|
||||
function redirectToPay(params: {
|
||||
user_id: string;
|
||||
return_url: string;
|
||||
total_fee: number;
|
||||
type: string;
|
||||
channel: "alipay" | "wxpay";
|
||||
device: "pc" | "h5" | "wechat";
|
||||
}) {
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = "/api/pay";
|
||||
form.target = "_self";
|
||||
form.style.display = "none";
|
||||
const fields: [string, string][] = [
|
||||
["user_id", params.user_id],
|
||||
["return_url", params.return_url],
|
||||
["total_fee", String(params.total_fee)],
|
||||
["type", params.type],
|
||||
["channel", params.channel],
|
||||
["device", params.device],
|
||||
["html", "1"],
|
||||
];
|
||||
fields.forEach(([k, v]) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = k;
|
||||
input.value = v;
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function getStorage(key: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
function setStorage(key: string, value: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
function removeStorage(key: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [paidType, setPaidType] = useState<string | null>(null);
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const savePaidType = useCallback((type: string | null) => {
|
||||
if (type) setStorage("paidType", type);
|
||||
else removeStorage("paidType");
|
||||
setPaidType(type);
|
||||
}, []);
|
||||
|
||||
const saveUserName = useCallback((name: string) => {
|
||||
setStorage("userName", name);
|
||||
setUserName(name);
|
||||
}, []);
|
||||
|
||||
const getOrCreateUserId = useCallback(() => {
|
||||
let userId = getStorage("userId");
|
||||
if (!userId) {
|
||||
userId = `user${Date.now()}`;
|
||||
setStorage("userId", userId);
|
||||
}
|
||||
return userId;
|
||||
}, []);
|
||||
|
||||
const checkPaymentFromPB = useCallback(async (userId: string) => {
|
||||
try {
|
||||
const pb = new PocketBase(PB_URL);
|
||||
const records = await pb.collection("payments").getList(1, 1, {
|
||||
filter: `user_id = "${userId}"`,
|
||||
sort: "-created",
|
||||
});
|
||||
|
||||
if (records.items.length > 0) {
|
||||
const latestType = records.items[0].type as string;
|
||||
const dbUserId = records.items[0].user_id as string;
|
||||
savePaidType(latestType);
|
||||
saveUserName(dbUserId);
|
||||
return latestType;
|
||||
} else {
|
||||
savePaidType(null);
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("查询 PocketBase 失败:", err);
|
||||
return getStorage("paidType");
|
||||
}
|
||||
}, [savePaidType, saveUserName]);
|
||||
|
||||
const clearAllStorage = useCallback(() => {
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
window.localStorage.clear();
|
||||
}
|
||||
removeStorage("userId");
|
||||
removeStorage("paidType");
|
||||
removeStorage("userName");
|
||||
setPaidType(null);
|
||||
setUserName(null);
|
||||
setLoading(false);
|
||||
alert("本地存储已清空");
|
||||
// 移除 URL 中的 ?type=clean,避免刷新时重复触发
|
||||
if (typeof window !== "undefined" && window.history.replaceState) {
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("清空存储失败:", error);
|
||||
alert("清空存储失败");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePay = useCallback(() => {
|
||||
const userId = getOrCreateUserId();
|
||||
const returnUrl = `${window.location.origin}/paid`;
|
||||
const env = getPayEnv();
|
||||
const channel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
|
||||
const device = getDeviceFromEnv(env);
|
||||
redirectToPay({
|
||||
user_id: userId,
|
||||
return_url: returnUrl,
|
||||
total_fee: 18800,
|
||||
type: "vip",
|
||||
channel,
|
||||
device,
|
||||
});
|
||||
}, [getOrCreateUserId]);
|
||||
|
||||
const handleCopyUsername = useCallback(() => {
|
||||
const name = userName || "";
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(name);
|
||||
alert("账号已复制");
|
||||
}
|
||||
}, [userName]);
|
||||
|
||||
// 微信 H5 支付完成后可能不跳转,轮询检测到已支付则刷新
|
||||
usePayStatusPoll(() => {
|
||||
savePaidType("vip");
|
||||
saveUserName(getStorage("userId") || "");
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get("type") === "clean") {
|
||||
clearAllStorage();
|
||||
return;
|
||||
}
|
||||
if (urlParams.get("paid") === "1") {
|
||||
savePaidType("vip");
|
||||
saveUserName(getStorage("userId") || "");
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
getOrCreateUserId();
|
||||
|
||||
const localType = getStorage("paidType");
|
||||
const localUserName = getStorage("userName");
|
||||
if (localType && localUserName) {
|
||||
setPaidType(localType);
|
||||
setUserName(localUserName);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = getStorage("userId");
|
||||
if (userId) {
|
||||
await checkPaymentFromPB(userId);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
init();
|
||||
}, [clearAllStorage, getOrCreateUserId, checkPaymentFromPB, savePaidType, saveUserName]);
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
|
||||
return (
|
||||
<div className={styles.index}>
|
||||
<div className={`${styles.indexBook} animate__animated animate__pulse`}>
|
||||
<div className={styles.indexBookLeft}>
|
||||
<div className={styles.leftTitle}>异度星球</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardItem}>
|
||||
<div className={styles.cardItemLeft}>
|
||||
<Image
|
||||
className={styles.cardItemLeftImg}
|
||||
src="/images/group.svg"
|
||||
alt="group"
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.cardItemRight}>
|
||||
<div className={styles.cardItemRightUp}>数字游民社群</div>
|
||||
<div className={styles.cardItemRightDown}>还在独自摸索吗?</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`markdown-body ${styles.markVip}`}
|
||||
dangerouslySetInnerHTML={{ __html: marked(indexMd) as string }}
|
||||
/>
|
||||
|
||||
<div className={styles.leftBtn}>
|
||||
{paidType === "vip" ? (
|
||||
<div className={styles.vipcode}>
|
||||
<div
|
||||
onClick={() => {
|
||||
window.location.href = "https://qun.hackrobot.cn";
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
👉星球地址: qun.hackrobot.cn
|
||||
</div>
|
||||
<div>账号:{getStorage("userName")}</div>
|
||||
<button onClick={handleCopyUsername}>复制账号</button>
|
||||
<div>密码:123456</div>
|
||||
<div>打开网页后,请点击"左上角头像",打开侧面栏</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (paidType) return;
|
||||
handlePay();
|
||||
}}
|
||||
className={styles.startRead}
|
||||
type="button"
|
||||
>
|
||||
🔒 解锁vip会员
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.indexBookImg}>
|
||||
<Image
|
||||
className={styles.bookImg}
|
||||
src="/images/touxiang.png"
|
||||
alt="avatar"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.svgItems}>
|
||||
<div className={styles.svgItem}>
|
||||
<div className={styles.bookImgItem}>
|
||||
<Image
|
||||
src="/images/laptop.svg"
|
||||
alt="laptop"
|
||||
width={80}
|
||||
height={80}
|
||||
/>
|
||||
<div className={styles.bookImgText}>无人直播</div>
|
||||
</div>
|
||||
<div className={styles.bookImgItem}>
|
||||
<Image
|
||||
src="/images/travel.svg"
|
||||
alt="travel"
|
||||
width={80}
|
||||
height={80}
|
||||
/>
|
||||
<div className={styles.bookImgText}>RPA自动化</div>
|
||||
</div>
|
||||
<div className={styles.bookImgItem}>
|
||||
<Image
|
||||
src="/images/code.svg"
|
||||
alt="code"
|
||||
width={80}
|
||||
height={80}
|
||||
/>
|
||||
<div className={styles.bookImgText}>聊天机器人</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
app/paid/page.tsx
Normal file
76
app/paid/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
|
||||
|
||||
export default function PaidPage() {
|
||||
const [status, setStatus] = useState<"pending" | "success" | "fail">("pending");
|
||||
const [fromUrl, setFromUrl] = useState<string | null>(null);
|
||||
|
||||
usePayStatusPoll(() => setStatus("success"));
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const paid = params.get("paid");
|
||||
const fail = params.get("fail");
|
||||
const from = params.get("from");
|
||||
if (paid === "1") setStatus("success");
|
||||
else if (fail === "1") setStatus("fail");
|
||||
if (from) setFromUrl(decodeURIComponent(from));
|
||||
}, []);
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg">
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 animate-spin items-center justify-center rounded-full border-4 border-sky-200 border-t-sky-500" />
|
||||
<h2 className="text-xl font-bold text-slate-800">支付结果确认中</h2>
|
||||
<p className="mt-3 text-sm text-slate-500">
|
||||
正在查询支付状态,请稍候…
|
||||
<br />
|
||||
(支付完成后将自动跳转)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "success") {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg">
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 text-5xl">
|
||||
✅
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">支付成功</h2>
|
||||
<p className="mt-3 text-slate-500">感谢您的支持,VIP 权益已开通</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
|
||||
>
|
||||
← 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg">
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-amber-50 text-5xl">
|
||||
⚠️
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">支付未完成</h2>
|
||||
<p className="mt-3 text-slate-500">支付已取消或未成功,请重试</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full border-2 border-slate-300 px-8 py-3 font-semibold text-slate-700 transition-colors hover:border-slate-400 hover:bg-slate-50"
|
||||
>
|
||||
← 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user