This commit is contained in:
eric
2026-03-12 03:54:50 -05:00
parent 170a9939aa
commit 40230dd646
20 changed files with 645 additions and 95 deletions

View File

@@ -0,0 +1,68 @@
/**
* 修改密码 - 用于 meetup 支付成功后强制修改默认密码
*/
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
const COOKIE_NAME = "pb_session";
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
function getAuthFromCookie(request: NextRequest): { userId: string; token: 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"));
if (payload?.userId && payload?.token) return { userId: payload.userId, token: payload.token };
return null;
} catch {
return null;
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const newPassword = String(body?.newPassword || "").trim();
const passwordConfirm = String(body?.passwordConfirm || "").trim();
if (newPassword.length < 8) {
return NextResponse.json({ ok: false, error: "密码至少 8 位" }, { status: 400 });
}
if (newPassword !== passwordConfirm) {
return NextResponse.json({ ok: false, error: "两次密码不一致" }, { status: 400 });
}
const auth = getAuthFromCookie(request);
if (!auth) {
return NextResponse.json({ ok: false, error: "请先登录" }, { status: 401 });
}
const pb = getPocketBaseConfig();
const 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,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return NextResponse.json(
{ ok: false, error: err?.message || "修改失败" },
{ status: 400 }
);
}
const r = NextResponse.json({ ok: true });
r.cookies.set(COOKIE_NEEDS_PW, "", { path: "/", maxAge: 0 });
return r;
} catch (e) {
console.error("change-password error:", e);
return NextResponse.json({ ok: false, error: "操作失败" }, { status: 500 });
}
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 });
return res;
}

60
app/api/auth/me/route.ts Normal file
View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
export async function GET(request: NextRequest) {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) {
return NextResponse.json({ ok: true, user: null });
}
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
const { token, userId, email } = payload;
if (!token || !userId) return NextResponse.json({ ok: true, user: null });
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/users/refresh`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const r = NextResponse.json({ ok: true, user: null });
r.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 });
return r;
}
const data = await res.json();
const newToken = data.token;
const newRecord = data.record;
if (newToken && newRecord?.id) {
const newPayload = JSON.stringify({
token: newToken,
userId: newRecord.id,
email: newRecord.email ?? email,
});
const encoded = Buffer.from(newPayload, "utf-8").toString("base64url");
const r = NextResponse.json({
ok: true,
user: { id: newRecord.id, email: newRecord.email ?? email },
token: newToken,
});
r.cookies.set(COOKIE_NAME, encoded, {
domain: AUTH_COOKIE_DOMAIN,
path: "/",
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
httpOnly: true,
});
return r;
}
return NextResponse.json({
ok: true,
user: { id: data.record?.id ?? userId, email: data.record?.email ?? email },
token: data.token,
});
} catch {
return NextResponse.json({ ok: true, user: null });
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const token = body?.token;
const record = body?.record;
if (!token || !record?.id) {
return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 });
}
const payload = JSON.stringify({
token,
userId: record.id,
email: record.email ?? "",
});
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, encoded, {
domain: AUTH_COOKIE_DOMAIN,
path: "/",
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
httpOnly: true,
});
return res;
}

View File

@@ -1,7 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
function getOrCreateUserId(): string {
const COOKIE_NAME = "pb_session";
function getOrCreateUserId(request: NextRequest): string {
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 */
}
}
return `user${Date.now()}`;
}
@@ -23,7 +34,7 @@ async function getAdminToken(): Promise<string | null> {
export async function POST(request: NextRequest) {
try {
const userId = getOrCreateUserId();
const userId = getOrCreateUserId(request);
const body = await request.json();
const formData = body && typeof body === "object" ? body : {};

View File

@@ -0,0 +1,110 @@
/**
* 加入游牧中国:确保用户存在
* 新用户:用默认密码 123456789 创建,支付成功后需修改
* 老用户:验证密码后登录
*/
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
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 });
}
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,
}),
});
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 newUser = await createRes.json();
const finalLogin = await tryLogin(DEFAULT_PASSWORD);
if (!finalLogin.ok) {
return NextResponse.json({ ok: false, error: "登录失败" }, { status: 500 });
}
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 });
}
}

View File

@@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
export async function GET(request: NextRequest) {
const need = request.cookies.get(COOKIE_NEEDS_PW)?.value === "1";
return NextResponse.json({ need });
}

View File

@@ -14,6 +14,7 @@ async function createPayOrder(
const payload = {
user_id,
site_id: "meetup",
total_fee: Number(total_fee) || config.defaultAmount,
type: type || config.defaultType,
channel,
@@ -23,7 +24,7 @@ async function createPayOrder(
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(`${config.apiUrl}/payh5`, {
const res = await fetch(`${config.apiUrl}/cnomadcna/payh5`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -56,7 +57,7 @@ export async function POST(request: NextRequest) {
const user_id = String(body?.user_id || "").trim();
const return_url = String(body?.return_url || "").trim() || `${SITE_URL}/zh/join/paid`;
const total_fee = Number(body?.total_fee) || 80;
const total_fee = Number(body?.total_fee) || 100;
const type = String(body?.type || "meetup");
const channel = String(body?.channel || "alipay");
const device = String(body?.device || "pc");

View File

@@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25000);
const res = await fetch(
`${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
`${config.apiUrl}/cnomadcna/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
{ cache: "no-store", signal: controller.signal }
).finally(() => clearTimeout(timeout));
const data = await res.json().catch(() => ({}));

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig, SITE_ID } from "@/config/services.config";
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) {
return NextResponse.json({ ok: true, vip: false });
}
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
const userId = payload?.userId;
if (!userId) return NextResponse.json({ ok: true, vip: false });
const token = await getAdminToken();
if (!token) return NextResponse.json({ ok: true, vip: 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}` } }
);
if (!res.ok) return NextResponse.json({ ok: true, vip: false });
const data = await res.json();
const items = data?.items ?? [];
const record = items[0];
const now = new Date().toISOString();
const isExpired = record?.expires_at && record.expires_at < now;
const vip = !!record && !isExpired;
return NextResponse.json({
ok: true,
vip,
expires_at: record?.expires_at ?? null,
});
} catch {
return NextResponse.json({ ok: true, vip: false });
}
}