's'
This commit is contained in:
81
app/api/meetup/_paymentApi.ts
Normal file
81
app/api/meetup/_paymentApi.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { DEFAULT_PAYMENT_API_URL } from "@/config/domain.config";
|
||||
import { getPaymentConfig } from "@/config/services.config";
|
||||
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
if (!hostname) return false;
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||
return true;
|
||||
}
|
||||
if (/^10\./.test(hostname) || /^192\.168\./.test(hostname)) {
|
||||
return true;
|
||||
}
|
||||
const match = hostname.match(/^172\.(\d{1,2})\./);
|
||||
if (!match) return false;
|
||||
const secondOctet = Number(match[1]);
|
||||
return secondOctet >= 16 && secondOctet <= 31;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url: string | undefined): string {
|
||||
return String(url || "").trim().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function getRequestHostname(request: NextRequest): string {
|
||||
const forwardedHost = request.headers.get("x-forwarded-host");
|
||||
const host = forwardedHost || request.headers.get("host") || "";
|
||||
return host.split(",")[0].trim().replace(/:\d+$/, "");
|
||||
}
|
||||
|
||||
function getMeetupApiCandidates(request: NextRequest): string[] {
|
||||
const configuredApiUrl = normalizeBaseUrl(getPaymentConfig().apiUrl);
|
||||
const localApiUrl = normalizeBaseUrl(DEFAULT_PAYMENT_API_URL);
|
||||
const hostname = getRequestHostname(request);
|
||||
const shouldPreferLocal =
|
||||
process.env.NODE_ENV === "development" || isPrivateHost(hostname);
|
||||
|
||||
const urls = shouldPreferLocal
|
||||
? [localApiUrl, configuredApiUrl]
|
||||
: [configuredApiUrl, localApiUrl];
|
||||
|
||||
return urls.filter((url, index) => url && urls.indexOf(url) === index);
|
||||
}
|
||||
|
||||
export async function postMeetupApi(
|
||||
request: NextRequest,
|
||||
path: string,
|
||||
body: unknown
|
||||
): Promise<{ status: number; data: unknown }> {
|
||||
const apiCandidates = getMeetupApiCandidates(request);
|
||||
if (apiCandidates.length === 0) {
|
||||
throw new Error("PAYMENT_API_URL is not configured");
|
||||
}
|
||||
|
||||
let lastResponse: { status: number; data: unknown } | null = null;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const apiUrl of apiCandidates) {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (res.ok || (res.status !== 404 && res.status < 500)) {
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
lastResponse = { status: res.status, data };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastResponse) {
|
||||
return lastResponse;
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("Meetup API request failed");
|
||||
}
|
||||
18
app/api/meetup/check-user/route.ts
Normal file
18
app/api/meetup/check-user/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { postMeetupApi } from "../_paymentApi";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const email = String(body?.email || "").trim();
|
||||
if (!email) {
|
||||
return NextResponse.json({ ok: false, error: "Please enter your email" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { status, data } = await postMeetupApi(request, "/api/meetup/check-user", { email });
|
||||
return NextResponse.json(data, { status });
|
||||
} catch (error) {
|
||||
console.error("check-user error:", error);
|
||||
return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
203
app/api/meetup/complete-order/route.ts
Normal file
203
app/api/meetup/complete-order/route.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPocketBaseConfig, getPaymentConfig, SITE_ID } from "@/config/services.config";
|
||||
import { getAdminToken } from "@/app/lib/pocketbase/admin";
|
||||
|
||||
const DEFAULT_PASSWORD = "12345678";
|
||||
|
||||
function parseOrderId(orderId: string): { userId: string; payType: string } {
|
||||
if (!orderId.includes("_order_")) return { userId: "", payType: "unknown" };
|
||||
const prefix = orderId.split("_order_")[0];
|
||||
if (prefix.includes("_")) {
|
||||
const lastUnderscore = prefix.lastIndexOf("_");
|
||||
return {
|
||||
userId: prefix.slice(0, lastUnderscore),
|
||||
payType: prefix.slice(lastUnderscore + 1).toLowerCase(),
|
||||
};
|
||||
}
|
||||
return { userId: prefix, payType: "unknown" };
|
||||
}
|
||||
|
||||
function decodePendingEmail(userId: string): string | null {
|
||||
if (!userId.startsWith("pending_")) return null;
|
||||
try {
|
||||
return Buffer.from(userId.slice(8), "hex").toString("utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryZpayPaid(orderId: string): Promise<boolean> {
|
||||
const config = getPaymentConfig();
|
||||
if (!config.apiUrl) {
|
||||
console.error("queryZpayPaid: PAYMENT_API_URL not configured");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const url = `${config.apiUrl}/cnomadcna/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`;
|
||||
const res = await fetch(url, { cache: "no-store", signal: controller.signal })
|
||||
.finally(() => clearTimeout(timeout));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!data?.paid) {
|
||||
console.warn(`queryZpayPaid: not paid yet, order_id=${orderId}, response=`, JSON.stringify(data));
|
||||
}
|
||||
return !!data?.paid;
|
||||
} catch (e) {
|
||||
console.error(`queryZpayPaid: fetch error, order_id=${orderId}`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSiteVip(
|
||||
base: string,
|
||||
adminToken: string,
|
||||
userId: string,
|
||||
orderId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const filter = `user_id = "${userId}" && site_id = "${SITE_ID}"`;
|
||||
const checkRes = await fetch(
|
||||
`${base}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`,
|
||||
{ headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store" }
|
||||
);
|
||||
if (checkRes.ok) {
|
||||
const checkData = await checkRes.json();
|
||||
const existing = (checkData?.items ?? [])[0];
|
||||
if (existing) {
|
||||
await fetch(`${base}/api/collections/site_vip/records/${existing.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` },
|
||||
body: JSON.stringify({ order_id: orderId, expires_at: "" }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const createRes = await fetch(`${base}/api/collections/site_vip/records`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` },
|
||||
body: JSON.stringify({ user_id: userId, site_id: SITE_ID, order_id: orderId, expires_at: "" }),
|
||||
});
|
||||
return createRes.ok;
|
||||
} catch (e) {
|
||||
console.error("ensureSiteVip error:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserByEmail(base: string, adminToken: string, email: string): Promise<string | null> {
|
||||
const createRes = await fetch(`${base}/api/collections/users/records`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` },
|
||||
body: JSON.stringify({ email, password: DEFAULT_PASSWORD, passwordConfirm: DEFAULT_PASSWORD }),
|
||||
});
|
||||
if (createRes.ok) {
|
||||
const data = await createRes.json();
|
||||
return data?.id ?? null;
|
||||
}
|
||||
if (createRes.status === 400) {
|
||||
const filter = `email="${email}"`;
|
||||
const getRes = await fetch(
|
||||
`${base}/api/collections/users/records?filter=${encodeURIComponent(filter)}&perPage=1`,
|
||||
{ headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store" }
|
||||
);
|
||||
if (getRes.ok) {
|
||||
const getData = await getRes.json();
|
||||
return (getData?.items ?? [])[0]?.id ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const orderId = String(body?.order_id || "").trim();
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ ok: false, error: "Missing order_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
let { userId, payType } = parseOrderId(orderId);
|
||||
if (!userId || payType !== "meetup") {
|
||||
return NextResponse.json({ ok: false, error: "订单格式无效" }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminToken = await getAdminToken();
|
||||
if (!adminToken) {
|
||||
console.error("complete-order: getAdminToken failed, check POCKETBASE_EMAIL/PASSWORD");
|
||||
return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 });
|
||||
}
|
||||
|
||||
const pb = getPocketBaseConfig();
|
||||
const base = pb.url.replace(/\/$/, "");
|
||||
|
||||
// 1. 先查 site_vip 是否已由异步通知创建
|
||||
const filter = `order_id = "${orderId}"`;
|
||||
const vipRes = await fetch(
|
||||
`${base}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`,
|
||||
{ headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store" }
|
||||
);
|
||||
let found = false;
|
||||
if (vipRes.ok) {
|
||||
const vipData = await vipRes.json();
|
||||
found = (vipData?.items ?? []).length > 0;
|
||||
}
|
||||
|
||||
// 2. 没找到 → 查 ZPAY 确认是否已支付,已支付则直接创建 site_vip
|
||||
if (!found) {
|
||||
const zpayPaid = await queryZpayPaid(orderId);
|
||||
if (!zpayPaid) {
|
||||
console.error(`complete-order: ZPAY 未确认支付 order_id=${orderId}`);
|
||||
return NextResponse.json({ ok: false, error: "订单不存在或未支付成功" }, { status: 400 });
|
||||
}
|
||||
console.log(`complete-order: ZPAY 已确认支付,主动创建 site_vip order_id=${orderId}`);
|
||||
|
||||
// pending_ 新用户:先创建用户
|
||||
let realUserId = userId;
|
||||
if (userId.startsWith("pending_")) {
|
||||
const email = decodePendingEmail(userId);
|
||||
if (!email) {
|
||||
return NextResponse.json({ ok: false, error: "订单格式无效" }, { status: 400 });
|
||||
}
|
||||
const newId = await createUserByEmail(base, adminToken, email);
|
||||
if (!newId) {
|
||||
return NextResponse.json({ ok: false, error: "创建用户失败" }, { status: 500 });
|
||||
}
|
||||
realUserId = newId;
|
||||
}
|
||||
|
||||
const created = await ensureSiteVip(base, adminToken, realUserId, orderId);
|
||||
if (!created) {
|
||||
return NextResponse.json({ ok: false, error: "VIP 开通失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. pending_ 新用户:登录并返回 token
|
||||
if (userId.startsWith("pending_")) {
|
||||
const email = decodePendingEmail(userId);
|
||||
if (!email) {
|
||||
return NextResponse.json({ ok: false, error: "订单格式无效" }, { status: 400 });
|
||||
}
|
||||
const loginRes = await fetch(`${base}/api/collections/users/auth-with-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity: email, password: DEFAULT_PASSWORD }),
|
||||
});
|
||||
if (!loginRes.ok) {
|
||||
return NextResponse.json({ ok: false, error: "登录失败,请稍后重试" }, { status: 500 });
|
||||
}
|
||||
const authData = await loginRes.json();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
token: authData.token,
|
||||
record: authData.record,
|
||||
is_new: true,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, token: null, record: null, is_new: false });
|
||||
} catch (error) {
|
||||
console.error("complete-order error:", error);
|
||||
return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,110 +1,30 @@
|
||||
/**
|
||||
* 加入游牧中国:确保用户存在
|
||||
* 新用户:用默认密码 123456789 创建,支付成功后需修改
|
||||
* 老用户:验证密码后登录
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPocketBaseConfig } from "@/config/services.config";
|
||||
import { postMeetupApi } from "../_paymentApi";
|
||||
|
||||
const DEFAULT_PASSWORD = "123456789";
|
||||
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
|
||||
|
||||
async function getAdminToken(): Promise<string | null> {
|
||||
const email = process.env.POCKETBASE_EMAIL;
|
||||
const password = process.env.POCKETBASE_PASSWORD;
|
||||
if (!email || !password) return null;
|
||||
const pb = getPocketBaseConfig();
|
||||
const res = await fetch(`${pb.url}/api/admins/auth-with-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity: email, password }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data?.token ?? null;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const email = String(body?.email || "").trim();
|
||||
const password = String(body?.password || "").trim();
|
||||
if (!email) {
|
||||
return NextResponse.json({ ok: false, error: "请输入邮箱" }, { status: 400 });
|
||||
return NextResponse.json({ ok: false, error: "Please enter your email" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pb = getPocketBaseConfig();
|
||||
const tryLogin = async (pwd: string) => {
|
||||
const res = await fetch(`${pb.url}/api/collections/users/auth-with-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity: email, password: pwd }),
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
let loginRes = await tryLogin(password || DEFAULT_PASSWORD);
|
||||
if (loginRes.ok) {
|
||||
const data = await loginRes.json();
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
user_id: data.record?.id,
|
||||
token: data.token,
|
||||
record: data.record,
|
||||
is_new: false,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
const errData = await loginRes.json().catch(() => ({}));
|
||||
const isNotFound = loginRes.status === 400 && (errData?.message?.includes("Invalid") || errData?.message?.includes("identity"));
|
||||
if (!isNotFound && password) {
|
||||
return NextResponse.json({ ok: false, error: "密码错误" }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminToken = await getAdminToken();
|
||||
if (!adminToken) {
|
||||
return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 });
|
||||
}
|
||||
|
||||
const createRes = await fetch(`${pb.url}/api/collections/users/records`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password: DEFAULT_PASSWORD,
|
||||
passwordConfirm: DEFAULT_PASSWORD,
|
||||
}),
|
||||
const { status, data } = await postMeetupApi(request, "/api/meetup/ensure-user", {
|
||||
email,
|
||||
password: password || undefined,
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const createErr = await createRes.json().catch(() => ({}));
|
||||
if (createRes.status === 400 && createErr?.data?.email?.message?.includes("already")) {
|
||||
return NextResponse.json({ ok: false, error: "该邮箱已注册,请输入密码登录" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: createErr?.message || "创建账号失败" }, { status: 500 });
|
||||
}
|
||||
const payload = typeof data === "object" && data !== null ? data as Record<string, unknown> : null;
|
||||
|
||||
const newUser = await createRes.json();
|
||||
const finalLogin = await tryLogin(DEFAULT_PASSWORD);
|
||||
if (!finalLogin.ok) {
|
||||
return NextResponse.json({ ok: false, error: "登录失败" }, { status: 500 });
|
||||
const nextRes = NextResponse.json(data, { status });
|
||||
if (status >= 200 && status < 300 && payload?.is_new) {
|
||||
nextRes.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 });
|
||||
}
|
||||
const authData = await finalLogin.json();
|
||||
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
user_id: authData.record?.id,
|
||||
token: authData.token,
|
||||
record: authData.record,
|
||||
is_new: true,
|
||||
});
|
||||
res.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 });
|
||||
return res;
|
||||
} catch (e) {
|
||||
console.error("ensure-user error:", e);
|
||||
return NextResponse.json({ ok: false, error: "操作失败" }, { status: 500 });
|
||||
return nextRes;
|
||||
} catch (error) {
|
||||
console.error("ensure-user error:", error);
|
||||
return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
9
app/api/meetup/set-needs-password-change/route.ts
Normal file
9
app/api/meetup/set-needs-password-change/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
|
||||
|
||||
export async function POST() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 });
|
||||
return res;
|
||||
}
|
||||
Reference in New Issue
Block a user