This commit is contained in:
eric
2026-03-12 19:54:58 -05:00
parent 14bbd3b306
commit f66859c032
31 changed files with 1132 additions and 312 deletions

View File

@@ -3,6 +3,7 @@
*/
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
const COOKIE_NAME = "pb_session";
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
@@ -38,24 +39,35 @@ export async function POST(request: NextRequest) {
}
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, {
const patchBody = { password: newPassword, passwordConfirm };
let res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${auth.token}`,
},
body: JSON.stringify({
password: newPassword,
passwordConfirm: passwordConfirm,
}),
body: JSON.stringify(patchBody),
});
if (!res.ok) {
const adminToken = await getAdminToken();
if (adminToken) {
res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify(patchBody),
});
}
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return NextResponse.json(
{ ok: false, error: err?.message || "修改失败" },
{ status: 400 }
);
const msg = err?.message || (err?.data && typeof err.data === "object" && Object.values(err.data)[0] && (Object.values(err.data)[0] as { message?: string })?.message) || "修改失败";
return NextResponse.json({ ok: false, error: msg }, { status: 400 });
}
const r = NextResponse.json({ ok: true });

View File

@@ -1,15 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { getAuthCookieDomain } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
export async function POST(request: NextRequest) {
const domain = getAuthCookieDomain(request);
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, "", {
...(domain && { domain }),
path: "/",
maxAge: 0,
});
res.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record<string, string | number | boolean>);
return res;
}

View File

@@ -1,13 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { getAuthCookieDomain } from "@/config/domain.config";
import { getPocketBaseConfig, SITE_ID } from "@/config/services.config";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
const COOKIE_NAME = "pb_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
async function checkVip(userId: string): Promise<boolean> {
try {
const token = await getAdminToken();
if (!token) return false;
const pb = getPocketBaseConfig();
const filter = `user_id = "${userId}" && site_id = "${SITE_ID}"`;
const res = await fetch(
`${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${token}` }, cache: "no-store" }
);
if (!res.ok) return false;
const data = await res.json();
const items = data?.items ?? [];
const record = items[0];
if (!record) return false;
const now = new Date().toISOString();
const isExpired = record?.expires_at && record.expires_at < now;
return !isExpired;
} catch {
return false;
}
}
export async function GET(request: NextRequest) {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
const domain = getAuthCookieDomain(request);
if (!cookie) {
return NextResponse.json({ ok: true, user: null });
}
@@ -17,12 +39,14 @@ export async function GET(request: NextRequest) {
if (!token || !userId) return NextResponse.json({ ok: true, user: null });
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/users/refresh`, {
const res = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-refresh`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) {
const r = NextResponse.json({ ok: true, user: null });
r.cookies.set(COOKIE_NAME, "", { ...(domain && { domain }), path: "/", maxAge: 0 });
r.cookies.set(COOKIE_NAME, "", { path: "/", maxAge: 0 });
return r;
}
const data = await res.json();
@@ -35,13 +59,13 @@ export async function GET(request: NextRequest) {
email: newRecord.email ?? email,
});
const encoded = Buffer.from(newPayload, "utf-8").toString("base64url");
const vip = await checkVip(newRecord.id);
const r = NextResponse.json({
ok: true,
user: { id: newRecord.id, email: newRecord.email ?? email },
user: { id: newRecord.id, email: newRecord.email ?? email, vip },
token: newToken,
});
r.cookies.set(COOKIE_NAME, encoded, {
...(domain && { domain }),
path: "/",
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
@@ -50,9 +74,11 @@ export async function GET(request: NextRequest) {
});
return r;
}
const uid = data.record?.id ?? userId;
const vip = uid ? await checkVip(uid) : false;
return NextResponse.json({
ok: true,
user: { id: data.record?.id ?? userId, email: data.record?.email ?? email },
user: { id: uid, email: data.record?.email ?? email, vip },
token: data.token,
});
} catch {

View File

@@ -1,8 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getAuthCookieDomain } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
@@ -19,15 +16,7 @@ export async function POST(request: NextRequest) {
});
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
const domain = getAuthCookieDomain(request);
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, encoded, {
...(domain && { domain }),
path: "/",
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
httpOnly: true,
});
res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record<string, string | number | boolean>);
return res;
}

View File

@@ -1,40 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
const COOKIE_NAME = "pb_session";
import { COOKIE_NAME } from "@/app/lib/auth-cookie";
function getOrCreateUserId(request: NextRequest): string {
function getUserIdFromCookie(request: NextRequest): string | null {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (cookie) {
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
if (payload?.userId) return payload.userId;
} catch {
/* ignore */
}
if (!cookie) return null;
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
return payload?.userId ?? null;
} catch {
return null;
}
return `user${Date.now()}`;
}
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 userId = getOrCreateUserId(request);
const userId = getUserIdFromCookie(request);
if (!userId) {
return NextResponse.json({ ok: false, error: "请先登录" }, { status: 401 });
}
const body = await request.json();
const formData = body && typeof body === "object" ? body : {};
@@ -73,27 +59,55 @@ export async function POST(request: NextRequest) {
const pb = getPocketBaseConfig();
const collection = pb.joinCollection;
const base = pb.url.replace(/\/$/, "");
const doCreate = async (authToken?: string) => {
const doUpsert = async (authToken: string) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
};
if (authToken) headers["Authorization"] = authToken;
return fetch(`${pb.url}/api/collections/${collection}/records`, {
const filter = `user_id="${userId}"`;
const listRes = await fetch(
`${base}/api/collections/${collection}/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${authToken}` }, cache: "no-store" }
);
const listData = await listRes.json().catch(() => ({}));
const existing = (listData?.items ?? [])[0];
if (existing?.id) {
const patchBody: Record<string, string | number> = {
nickname: record.nickname,
occupation: record.occupation,
reason: record.reason,
single: record.single,
wechatId: record.wechatId,
gender: record.gender,
education: record.education,
gradYear: record.gradYear,
phone: record.phone,
};
if ("media" in record) patchBody.media = record.media as string;
return fetch(`${base}/api/collections/${collection}/records/${existing.id}`, {
method: "PATCH",
headers,
body: JSON.stringify(patchBody),
});
}
return fetch(`${base}/api/collections/${collection}/records`, {
method: "POST",
headers,
body: JSON.stringify(record),
});
};
let res = await doCreate();
if (res.status === 403) {
const token = await getAdminToken();
if (token) res = await doCreate(token);
const token = await getAdminToken();
if (!token) {
return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 });
}
const res = await doUpsert(token);
if (!res.ok) {
const errText = await res.text();
console.error("PocketBase create error:", res.status, errText);

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
import { COOKIE_NAME } from "@/app/lib/auth-cookie";
function getUserIdFromCookie(request: NextRequest): string | null {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) return null;
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
return payload?.userId ?? null;
} catch {
return null;
}
}
const REQUIRED_FIELDS = ["nickname", "occupation", "reason", "single", "wechatId", "gender", "phone"];
function isRecordComplete(record: Record<string, unknown>): boolean {
return REQUIRED_FIELDS.every((key) => {
const v = record[key];
return v != null && String(v).trim() !== "";
});
}
export async function GET(request: NextRequest) {
try {
const userId = getUserIdFromCookie(request);
if (!userId) {
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
}
const token = await getAdminToken();
if (!token) {
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
}
const pb = getPocketBaseConfig();
const base = pb.url.replace(/\/$/, "");
const filter = `user_id="${userId}"`;
const res = await fetch(
`${base}/api/collections/${pb.joinCollection}/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${token}` }, cache: "no-store" }
);
if (!res.ok) {
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
}
const data = await res.json().catch(() => ({}));
const record = (data?.items ?? [])[0];
if (!record) {
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
}
const isComplete = isRecordComplete(record as Record<string, unknown>);
return NextResponse.json({ ok: true, hasRecord: true, isComplete });
} catch {
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
}
}

View 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");
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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 });
}
}

View 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;
}

View File

@@ -7,7 +7,7 @@ async function createPayOrder(
return_url: string,
total_fee?: number,
type?: string,
channel = "alipay"
channel = "alipay"
) {
const config = getPaymentConfig();
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
@@ -81,7 +81,7 @@ export async function POST(request: NextRequest) {
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(String(v))}" />`)
.join("\n");
const orderId = String((params as Record<string, unknown>)?.out_trade_no || "").trim();
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>跳转支付...</title></head><body><p>正在跳转到支付页面...</p><form id="f" action="${escapeAttr(payUrl)}" method="POST">${inputs}</form><script>document.getElementById("f").submit();</script></body></html>`;
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title><style>body{margin:0;background:#f0f4f8;opacity:0;}</style></head><body><form id="f" action="${escapeAttr(payUrl)}" method="POST">${inputs}</form><script>document.getElementById("f").submit();</script></body></html>`;
const res = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
if (orderId) {
res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });

View File

@@ -1,23 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig, SITE_ID } from "@/config/services.config";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
const COOKIE_NAME = "pb_session";
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 GET(request: NextRequest) {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) {