From 37d0883ce94f754ce83ebe018415d7e58e477c7f Mon Sep 17 00:00:00 2001 From: eric Date: Thu, 12 Mar 2026 19:50:25 -0500 Subject: [PATCH] '' --- ANALYSIS_REPORT.md | 76 +++++++++++++ BUGS_AND_ISSUES_SUMMARY.md | 96 ++++++++++++++++ .../[moduleIndex]/[lessonIndex]/page.tsx | 103 ++++++++++++++---- app/api/auth/login/route.ts | 45 ++++++++ app/api/auth/logout/route.ts | 11 +- app/api/auth/me/route.ts | 21 +--- app/api/auth/register/route.ts | 62 +++++++++++ app/api/auth/sync-session/route.ts | 15 +-- app/components/AuthModal.tsx | 91 ++++++++-------- app/components/Community.tsx | 12 +- app/components/Footer.tsx | 75 ++++++------- app/components/Header.tsx | 15 +-- app/components/Roadmap.tsx | 36 +----- app/components/course/CTASection.tsx | 91 ++++++++++++++-- app/components/course/CourseHero.tsx | 95 +++++++++++++--- app/layout.tsx | 4 +- app/lib/auth-cookie.ts | 34 ++++++ app/lib/locale-link.tsx | 9 +- app/lib/useAuthAndVip.ts | 1 - app/lib/useCrossSiteUrls.ts | 5 + config/domain.config.ts | 23 ++-- package.json | 4 +- 22 files changed, 688 insertions(+), 236 deletions(-) create mode 100644 ANALYSIS_REPORT.md create mode 100644 BUGS_AND_ISSUES_SUMMARY.md create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/lib/auth-cookie.ts create mode 100644 app/lib/useCrossSiteUrls.ts diff --git a/ANALYSIS_REPORT.md b/ANALYSIS_REPORT.md new file mode 100644 index 0000000..7e39856 --- /dev/null +++ b/ANALYSIS_REPORT.md @@ -0,0 +1,76 @@ +# 项目分析报告 + +## 一、项目架构概览 + +| 项目 | 端口 | 支付 API 前缀 | 站点 ID | 本地地址 | 线上地址 | +|------|------|---------------|---------|----------|----------| +| digital | 3000 | /digital/payh5 | digital | http://192.168.41.222:3000 | digital.hackrobot.cn | +| cnomadcna | 3001 | /cnomadcna/payh5 | meetup | http://192.168.41.222:3001 | meetup.hackrobot.cn | +| nomadvip | 3002 | /nomadvip/payh5 | vip | http://192.168.41.222:3002 | vip.hackrobot.cn | +| payjsapi | 8007 | - | - | http://192.168.41.222:8007 | api.hackrobot.cn | + +## 二、PocketBase 数据表 + +- **users**: 用户认证 +- **payments**: 支付记录 +- **site_vip**: 站点 VIP 权限 (user_id, site_id, order_id, expires_at) +- **sites**: 站点配置 +- **solan**: 游牧中国申请表单 (需与 users 关联) + +## 三、Bug 分析与修复方案 + +### Bug 1: digital 首页登录注册异常 + +**可能原因:** +- Cookie 未设置 domain,跨子域或本地 IP 访问时可能失效 +- auth/me 使用 `/api/users/refresh`,需确认 PocketBase 版本兼容 +- 本地开发时 PAYMENT_API_URL 默认 8700,用户期望 8007 + +**修复:** +- 统一 Cookie 设置逻辑,生产环境设置 AUTH_COOKIE_DOMAIN +- 本地开发不设置 domain +- 修正 payjsapi 端口配置 + +### Bug 2: digital /course 立即报名流程 + +**当前逻辑:** 已实现登录校验→支付→VIP 解锁,CTASection 的 handleEnroll 正确。 + +**需确认:** 支付返回后 useAuthAndVip 会 refetch,course 页面需轮询支付状态。 + +### Bug 3: cnomadcna 首页「加入游牧中国」流程不完整 + +**需求:** +1. 检查 users 是否存在 +2. 校验 VIP 状态 +3. 老用户:弹窗密码校验,可修改密码,无 VIP 进入支付 +4. 新用户:直接进入支付 +5. 支付失败:users 新增记录但无 VIP +6. 支付成功:解锁 VIP + +**当前问题:** +- check-user 只返回 exists,未返回 vip +- 老用户有 VIP 时仍会弹窗要求支付 +- JoinModal 未区分「有 VIP 直接进入」逻辑 + +**修复:** 扩展 check-user 返回 vip,HeroSection/JoinModal 按 vip 分支处理。 + +### Bug 4: cnomadcna 支付成功后首页未显示登录 + +**原因:** cnomadcna 首页无 Header/Navbar 展示登录态。 + +**修复:** 添加 TopBar 或复用 layout 展示 auth 状态,join/paid 完成后调用 pbSaveAuth 同步 localStorage。 + +### Bug 5: cnomadcna /join 提交申请 + +**需求:** 必须先登录,solan 与 users 关联。 + +**当前问题:** api/join 的 getOrCreateUserId 在无 cookie 时返回 `user${Date.now()}` 假 ID,solan 未正确关联真实用户。 + +**修复:** 未登录时返回 401,强制登录后再提交;solan.user_id 使用 cookie 中的真实 userId。 + +## 四、环境配置 + +将创建各项目的 .env.example,本地默认: +- PAYMENT_API_URL: http://192.168.41.222:8007 +- NEXT_PUBLIC_POCKETBASE_URL: https://pocketbase.hackrobot.cn(或本地 PB 地址) +- 本地不设置 AUTH_COOKIE_DOMAIN diff --git a/BUGS_AND_ISSUES_SUMMARY.md b/BUGS_AND_ISSUES_SUMMARY.md new file mode 100644 index 0000000..653422e --- /dev/null +++ b/BUGS_AND_ISSUES_SUMMARY.md @@ -0,0 +1,96 @@ +# Bug 与问题总结 + +基于代码分析,发现并已修复/待关注的问题如下。 + +--- + +## 一、已修复的 Bug + +### 1. digital 首页登录注册异常 + +| 问题 | 原因 | 修复 | +|------|------|------| +| 登录/注册后状态异常 | Cookie 未设置 domain,跨子域失效 | 新增 `auth-cookie.ts`,生产环境支持 `AUTH_COOKIE_DOMAIN` | +| auth/me 校验失败 | 使用已废弃的 `/api/users/refresh` | 改为 `POST /api/collections/users/auth-refresh` | +| AuthModal 类型错误 | `pbSaveAuth` 传入 record 的 email 可能为 undefined | 显式传入 `{ id, email: email ?? "" }` | + +### 2. cnomadcna 首页「加入游牧中国」流程不完整 + +| 问题 | 原因 | 修复 | +|------|------|------| +| 老用户有 VIP 仍被要求支付 | check-user 只返回 exists,未返回 vip | payjsapi 扩展 check-user 返回 `vip`、`user_id` | +| 无 VIP 状态判断 | 前端未按 vip 分支处理 | HeroSection 在 exists 且 vip 时提示「您已是会员」 | + +### 3. cnomadcna 支付成功后首页未显示登录 + +| 问题 | 原因 | 修复 | +|------|------|------| +| 支付完成返回首页仍显示未登录 | join/paid 未同步 localStorage,Navbar 不刷新 | join/paid 调用 `pbSaveAuth` 并派发 `auth:updated`,Navbar 监听并 refetch | + +### 4. cnomadcna /join 提交申请 + +| 问题 | 原因 | 修复 | +|------|------|------| +| 未登录可提交 | getOrCreateUserId 无 cookie 时返回假 ID | 未登录返回 401,强制先登录 | +| solan 未关联真实用户 | 使用 `user${Date.now()}` 作为 user_id | 使用 cookie 中的真实 userId | + +### 5. 环境与配置 + +| 问题 | 原因 | 修复 | +|------|------|------| +| payjsapi 端口不一致 | 默认 8700,用户期望 8007 | run.py 支持 `PORT` 环境变量,默认 8007 | +| 各项目默认支付 API 端口 | domain.config 使用 8700 | 统一改为 127.0.0.1:8007 | + +--- + +## 二、潜在风险与待关注点 + +### 1. PocketBase 管理员认证 + +- **现象**:check-user、ensure-user、vip/check 依赖 `POCKETBASE_EMAIL`、`POCKETBASE_PASSWORD` +- **风险**:未配置时返回「服务配置错误」或 vip 始终为 false +- **建议**:确保各项目 `.env.local` 中正确配置管理员账号 + +### 2. ZPAY 回调地址 + +- **现象**:payjsapi 的 `BASE_URL` 需为 ZPAY 可访问的公网地址 +- **风险**:本地调试时 ZPAY 无法回调 localhost/内网,支付成功但 PocketBase 不更新 +- **建议**:本地测试可用 ngrok 暴露 8007,或部署到公网后测试回调 + +### 3. 跨域 Cookie + +- **现象**:digital/meetup/vip 三站共享登录需 `AUTH_COOKIE_DOMAIN=.hackrobot.cn` +- **风险**:生产环境未配置时,各子域登录态不共享 +- **建议**:生产部署时在 `.env.local` 中设置 `AUTH_COOKIE_DOMAIN=.hackrobot.cn` + +### 4. cnomadcna 新用户预创建逻辑 + +- **现象**:`isPrivateDevHost` 时新用户会先 ensure-user 再支付,生产环境用 `pending_hex(email)` 作为 user_id +- **风险**:生产环境新用户支付成功后才创建 users,逻辑正确;本地预创建可能掩盖问题 +- **建议**:保持现状,本地与生产流程略有差异 + +### 5. digital 支付 return_url + +- **现象**:course 页「立即报名」的 return_url 为 `/course/0/0` +- **风险**:支付返回后需轮询支付状态,usePayStatusPoll 依赖 `join_pay_order` cookie;digital 的 pay 路由可能未设置该 cookie +- **建议**:确认 digital 支付返回后 course 页能正确轮询并刷新 VIP 状态 + +--- + +## 三、代码质量与结构 + +| 类型 | 说明 | +|------|------| +| 重复逻辑 | 三个前端的 auth、pay、vip 逻辑相似,可考虑抽公共库 | +| 硬编码 | 部分文案、金额、URL 散落代码中,建议集中到 config | +| 错误处理 | 部分 API 的 catch 仅 `console.error`,未统一错误格式 | +| 类型安全 | AuthModal 等处的 record 类型可再收紧 | + +--- + +## 四、本次修改项(当前会话) + +1. **支付测试价格**:所有前端默认 1 元(100 分) +2. **默认 zpay 渠道**:各项目 `.env.local` 设置 `PAYMENT_PROVIDER=zpay`,cnomadcna 默认 channel 改为 alipay +3. **.env.local**:为 digital、cnomadcna、nomadvip、payjsapi 创建本地调试配置 +4. **cnomadcna 支付页**:/api/pay 返回的 HTML 使用 `opacity:0` 隐藏「正在跳转」提示,直接进入支付流程 diff --git a/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx b/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx index a52199c..9869957 100644 --- a/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx +++ b/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx @@ -8,16 +8,27 @@ import { LessonMarkdown } from "./LessonMarkdown"; import { getLesson } from "@/config/lessons"; import { canWatchLesson } from "@/app/lib/course-payment"; import { useAuthAndVip } from "@/app/lib/useAuthAndVip"; +import { getStoredToken, getStoredUser } from "@/app/lib/pocketbase"; import { setLastWatched, markCompleted } from "@/app/lib/learning"; import { DOWNLOAD_CONFIG } from "@/config/course"; import Header from "@/app/components/Header"; +import AuthModal from "@/app/components/AuthModal"; +import { + getPayEnv, + getRecommendedChannel, + mustUseWxPay, + redirectToPay, + getDeviceFromEnv, +} from "@/app/lib/payment"; export default function LessonPage() { const params = useParams(); const router = useRouter(); const { t } = useTranslation("community"); - const { vip } = useAuthAndVip(); + const { user, vip } = useAuthAndVip(); const [toast, setToast] = useState(false); + const [authOpen, setAuthOpen] = useState(false); + const [payRedirecting, setPayRedirecting] = useState(false); const moduleIndex = Number(params.moduleIndex); const lessonIndex = Number(params.lessonIndex); @@ -29,6 +40,57 @@ export default function LessonPage() { if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name); }, [moduleIndex, lessonIndex, lesson?.name, unlocked]); + const startPay = (userId: string) => { + const env = getPayEnv(); + const channel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env); + const device = getDeviceFromEnv(env); + const returnUrl = `${window.location.origin}${window.location.pathname}`; + + redirectToPay({ + user_id: userId, + return_url: returnUrl, + channel, + device, + useJoinConfig: true, + }); + }; + + const handleEnroll = async () => { + if (payRedirecting) return; + setPayRedirecting(true); + + try { + if (user?.id) { + startPay(user.id); + return; + } + + const meRes = await fetch("/api/auth/me", { credentials: "include" }); + const meData = await meRes.json().catch(() => ({})); + if (meData?.user?.id) { + startPay(meData.user.id); + return; + } + + const storedUser = getStoredUser(); + const storedToken = getStoredToken(); + if (storedUser?.id && storedToken) { + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: storedToken, record: storedUser }), + credentials: "include", + }).catch(() => null); + startPay(storedUser.id); + return; + } + + setAuthOpen(true); + } finally { + setPayRedirecting(false); + } + }; + if (!lesson || isNaN(moduleIndex) || isNaN(lessonIndex)) { return (
@@ -36,14 +98,12 @@ export default function LessonPage() {

章节不存在

-

- 请检查链接或返回课程首页 -

+

请检查链接或返回课程首页

- ← 返回首页 + 返回首页
@@ -56,7 +116,6 @@ export default function LessonPage() {
- {/* 返回导航 */} - {/* 标题 */}

{lesson.name} {isFreeTrial && ( @@ -83,11 +141,8 @@ export default function LessonPage() { )}

-

- 时长 {lesson.duration} -

+

时长 {lesson.duration}

- {/* 视频播放器 / 锁定 overlay */}
{unlocked ? ( 🔒

报名解锁本章节

- - 立即报名 - + {payRedirecting ? "跳转支付中..." : "立即报名"} +
)}
- {/* 配套文档 - 仅解锁后显示 */} {unlocked && (

@@ -126,7 +182,6 @@ export default function LessonPage() {

)} - {/* 网盘下载 */} {DOWNLOAD_CONFIG.enabled && unlocked && (

@@ -135,9 +190,7 @@ export default function LessonPage() { {DOWNLOAD_CONFIG.title}

-

- 课程配套资料已上传至网盘,报名后可前往下载 -

+

课程配套资料已上传至网盘,报名后可前往下载

)} + setAuthOpen(false)} + onSuccess={() => { + setAuthOpen(false); + void handleEnroll(); + }} + />
); } diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..06a132e --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig } from "@/config/services.config"; +import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const identity = String(body?.identity || body?.email || "").trim(); + const password = String(body?.password || ""); + + if (!identity || !password) { + return NextResponse.json({ ok: false, error: "Missing identity or password" }, { status: 400 }); + } + + const pb = getPocketBaseConfig(); + const pbRes = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity, password }), + }); + + const pbData = await pbRes.json().catch(() => ({})); + if (!pbRes.ok || !pbData?.token || !pbData?.record?.id) { + return NextResponse.json( + { ok: false, error: pbData?.message || "Login failed" }, + { status: pbRes.status || 401 } + ); + } + + const payload = JSON.stringify({ + token: pbData.token, + userId: pbData.record.id, + email: pbData.record.email ?? identity, + }); + const encoded = Buffer.from(payload, "utf-8").toString("base64url"); + + const res = NextResponse.json({ + ok: true, + token: pbData.token, + record: pbData.record, + user: { id: pbData.record.id, email: pbData.record.email ?? identity }, + }); + res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record); + return res; +} + diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 7e07949..ededd1c 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -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); return res; } diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts index c1b7864..68bc56d 100644 --- a/app/api/auth/me/route.ts +++ b/app/api/auth/me/route.ts @@ -1,13 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { getPocketBaseConfig } from "@/config/services.config"; -import { getAuthCookieDomain } from "@/config/domain.config"; - -const COOKIE_NAME = "pb_session"; -const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; +import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; 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,18 +13,18 @@ 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}` }, }); 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, "", getCookieOptions(true, getHostFromRequest(request)) as Record); return r; } const data = await res.json(); const newToken = data.token; const newRecord = data.record; - // 刷新成功:回写新 token 到 Cookie,保持登录态长期有效 if (newToken && newRecord?.id) { const newPayload = JSON.stringify({ token: newToken, @@ -41,14 +37,7 @@ export async function GET(request: NextRequest) { user: { id: newRecord.id, email: newRecord.email ?? email }, token: newToken, }); - r.cookies.set(COOKIE_NAME, encoded, { - ...(domain && { domain }), - path: "/", - maxAge: COOKIE_MAX_AGE, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - httpOnly: true, - }); + r.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record); return r; } return NextResponse.json({ diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..fb898dc --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig } from "@/config/services.config"; +import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const email = String(body?.email || "").trim(); + const password = String(body?.password || ""); + const passwordConfirm = String(body?.passwordConfirm || ""); + + if (!email || !password || !passwordConfirm) { + return NextResponse.json( + { ok: false, error: "Missing email/password/passwordConfirm" }, + { status: 400 } + ); + } + + const pb = getPocketBaseConfig(); + const createRes = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/records`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, passwordConfirm }), + }); + + const createData = await createRes.json().catch(() => ({})); + if (!createRes.ok) { + return NextResponse.json( + { ok: false, error: createData?.message || "Register failed" }, + { status: createRes.status || 400 } + ); + } + + const loginRes = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password }), + }); + const loginData = await loginRes.json().catch(() => ({})); + if (!loginRes.ok || !loginData?.token || !loginData?.record?.id) { + return NextResponse.json( + { ok: false, error: loginData?.message || "Auto login failed after register" }, + { status: loginRes.status || 401 } + ); + } + + const payload = JSON.stringify({ + token: loginData.token, + userId: loginData.record.id, + email: loginData.record.email ?? email, + }); + const encoded = Buffer.from(payload, "utf-8").toString("base64url"); + + const res = NextResponse.json({ + ok: true, + token: loginData.token, + record: loginData.record, + user: { id: loginData.record.id, email: loginData.record.email ?? email }, + }); + res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record); + return res; +} + diff --git a/app/api/auth/sync-session/route.ts b/app/api/auth/sync-session/route.ts index 6fa0780..4c1dac3 100644 --- a/app/api/auth/sync-session/route.ts +++ b/app/api/auth/sync-session/route.ts @@ -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); return res; } diff --git a/app/components/AuthModal.tsx b/app/components/AuthModal.tsx index 539c353..1692ee6 100644 --- a/app/components/AuthModal.tsx +++ b/app/components/AuthModal.tsx @@ -1,11 +1,7 @@ "use client"; import { useState, type FormEvent } from "react"; -import { - pbLogin, - pbRegister, - pbSaveAuth, -} from "@/app/lib/pocketbase"; +import { pbSaveAuth } from "@/app/lib/pocketbase"; interface AuthModalProps { isOpen: boolean; @@ -13,6 +9,14 @@ interface AuthModalProps { onSuccess: (email: string) => void; } +type AuthResult = { + ok: boolean; + token?: string; + record?: { id: string; email?: string; [key: string]: unknown }; + error?: string; + message?: string; +}; + export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps) { const [mode, setMode] = useState<"login" | "register">("login"); const [email, setEmail] = useState(""); @@ -27,40 +31,45 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps setLoading(true); try { - let result; if (mode === "register") { if (password !== passwordConfirm) { - setError("两次密码不一致"); + setError("Passwords do not match"); setLoading(false); return; } if (password.length < 8) { - setError("密码至少 8 位"); + setError("Password must be at least 8 characters"); setLoading(false); return; } - result = await pbRegister(email, password, passwordConfirm); - } else { - result = await pbLogin(email, password); } - pbSaveAuth(result.token, result.record); - try { - await fetch("/api/auth/sync-session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: result.token, record: result.record }), - credentials: "include", - }); - } catch { - /* ignore */ + + const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register"; + const payload = + mode === "login" + ? { identity: email, password } + : { email, password, passwordConfirm }; + + const res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + }); + const data = (await res.json().catch(() => ({}))) as AuthResult; + + if (!res.ok || !data?.ok || !data?.token || !data?.record) { + throw new Error(data?.error || data?.message || "Authentication failed"); } + + pbSaveAuth(data.token, { id: data.record.id, email: data.record.email ?? email }); onSuccess(email); onClose(); setEmail(""); setPassword(""); setPasswordConfirm(""); } catch (err) { - setError(err instanceof Error ? err.message : "操作失败"); + setError(err instanceof Error ? err.message : "Authentication failed"); } finally { setLoading(false); } @@ -70,32 +79,26 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps return (
-
+
-

- {mode === "login" ? "登录" : "注册"} -

+

{mode === "login" ? "Login" : "Register"}

- {mode === "login" ? "使用邮箱密码登录" : "创建新账号"} + {mode === "login" ? "Use email and password" : "Create a new account"}

- +
- + setPassword(e.target.value)} - placeholder="至少 8 位" + placeholder="At least 8 characters" required minLength={mode === "register" ? 8 : 1} className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500" @@ -119,33 +122,31 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
{mode === "register" && (
- + setPasswordConfirm(e.target.value)} - placeholder="再次输入密码" + placeholder="Enter password again" required className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500" />
)} - {error && ( -

{error}

- )} + {error &&

{error}

}

{mode === "login" ? ( <> - 还没有账号?{" "} + No account?{" "} ) : ( <> - 已有账号?{" "} + Already have an account?{" "} )} diff --git a/app/components/Community.tsx b/app/components/Community.tsx index 5ba82a9..9d04ac5 100644 --- a/app/components/Community.tsx +++ b/app/components/Community.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { Link, useTranslation } from "@/i18n/navigation"; import { TOPIC_IDS } from "@/config"; import type { ResourceData } from "@/app/lib/theme-data"; +import { useCrossSiteUrls } from "@/app/lib/useCrossSiteUrls"; import ResourceNavigation from "./ResourceNavigation"; const mainModuleKeys = ["destinations", "vipEntry", "appDownload"] as const; @@ -18,11 +19,6 @@ const linkIcons: Record = { cloudPhone: "☁️", unmannedLive: "📺", }; -const linkHrefs: Record = { - destinations: { href: "https://meetup.hackrobot.cn/", newTab: true }, - vipEntry: { href: "https://vip.hackrobot.cn/", newTab: true }, - appDownload: { href: "/app" }, -}; type CommunityProps = { resourceData?: ResourceData; @@ -31,6 +27,12 @@ type CommunityProps = { export default function Community({ resourceData }: CommunityProps) { const { t } = useTranslation("community"); const [toast, setToast] = useState(false); + const { meetupUrl, vipUrl } = useCrossSiteUrls(); + const linkHrefs: Record = { + destinations: { href: meetupUrl, newTab: true }, + vipEntry: { href: vipUrl, newTab: true }, + appDownload: { href: "/app" }, + }; const showUpdating = () => { setToast(true); diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index b51f13b..d08f277 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -1,46 +1,47 @@ "use client"; import { Link, useTranslation } from "@/i18n/navigation"; - -const footerSections = [ - { - titleKey: "guide" as const, - links: [ - { labelKey: "roadmap" as const, href: "#roadmap" }, - { labelKey: "tools" as const, href: "#tools" }, - { labelKey: "destinations" as const, href: "https://meetup.hackrobot.cn/", openInNewTab: true }, - ], - }, - { - titleKey: "resources" as const, - links: [ - { labelKey: "remoteJobs" as const, href: "#" }, - { labelKey: "visa" as const, href: "#" }, - { labelKey: "coliving" as const, href: "#" }, - { labelKey: "insurance" as const, href: "#" }, - ], - }, - { - titleKey: "community" as const, - links: [ - { labelKey: "discord" as const, href: "#" }, - { labelKey: "wechat" as const, href: "https://vip.hackrobot.cn/", openInNewTab: true }, - { labelKey: "jike" as const, href: "#" }, - ], - }, - { - titleKey: "about" as const, - links: [ - { labelKey: "aboutUs" as const, href: "/about", internal: true }, - { labelKey: "recruit" as const, href: "/jobs", internal: true }, - { labelKey: "privacy" as const, href: "/privacy", internal: true, openInNewTab: true }, - { labelKey: "contact" as const, href: "#" }, - ], - }, -]; +import { useCrossSiteUrls } from "@/app/lib/useCrossSiteUrls"; export default function Footer() { const { t } = useTranslation("footer"); + const { meetupUrl, vipUrl } = useCrossSiteUrls(); + const footerSections = [ + { + titleKey: "guide" as const, + links: [ + { labelKey: "roadmap" as const, href: "#roadmap" }, + { labelKey: "tools" as const, href: "#tools" }, + { labelKey: "destinations" as const, href: meetupUrl, openInNewTab: true }, + ], + }, + { + titleKey: "resources" as const, + links: [ + { labelKey: "remoteJobs" as const, href: "#" }, + { labelKey: "visa" as const, href: "#" }, + { labelKey: "coliving" as const, href: "#" }, + { labelKey: "insurance" as const, href: "#" }, + ], + }, + { + titleKey: "community" as const, + links: [ + { labelKey: "discord" as const, href: "#" }, + { labelKey: "wechat" as const, href: vipUrl, openInNewTab: true }, + { labelKey: "jike" as const, href: "#" }, + ], + }, + { + titleKey: "about" as const, + links: [ + { labelKey: "aboutUs" as const, href: "/about", internal: true }, + { labelKey: "recruit" as const, href: "/jobs", internal: true }, + { labelKey: "privacy" as const, href: "/privacy", internal: true, openInNewTab: true }, + { labelKey: "contact" as const, href: "#" }, + ], + }, + ]; return (

diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 39bff0f..ffba8c8 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -3,17 +3,11 @@ import { useState, useEffect } from "react"; import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation"; import { getStoredUserEmail } from "@/app/lib/pocketbase"; +import { useCrossSiteUrls } from "@/app/lib/useCrossSiteUrls"; import AuthModal from "./AuthModal"; import ThemeToggle from "./ThemeToggle"; import UserAvatar from "./UserAvatar"; -const navLinks = [ - { labelKey: "roadmap" as const, href: "#roadmap" }, - { labelKey: "tools" as const, href: "#tools" }, - { labelKey: "community" as const, href: "https://vip.hackrobot.cn/", external: true }, - { labelKey: "resources" as const, href: "#resources" }, -]; - export default function Header() { const { t } = useTranslation("common"); const { t: tNav } = useTranslation("nav"); @@ -24,6 +18,13 @@ export default function Header() { const [scrolled, setScrolled] = useState(false); const [authOpen, setAuthOpen] = useState(false); const [userEmail, setUserEmail] = useState(null); + const { vipUrl } = useCrossSiteUrls(); + const navLinks = [ + { labelKey: "roadmap" as const, href: "#roadmap" }, + { labelKey: "tools" as const, href: "#tools" }, + { labelKey: "community" as const, href: vipUrl, external: true }, + { labelKey: "resources" as const, href: "#resources" }, + ]; useEffect(() => { let mounted = true; diff --git a/app/components/Roadmap.tsx b/app/components/Roadmap.tsx index 84a3974..b73f3f7 100644 --- a/app/components/Roadmap.tsx +++ b/app/components/Roadmap.tsx @@ -1,9 +1,6 @@ "use client"; -import { useState } from "react"; -import { Link, useTranslation, useRouter } from "@/i18n/navigation"; -import { useAuthAndVip } from "@/app/lib/useAuthAndVip"; -import AuthModal from "@/app/components/AuthModal"; +import { Link, useTranslation } from "@/i18n/navigation"; const days = [ { day: 1, icon: "👋", color: "bg-sky-500" }, @@ -17,9 +14,6 @@ const days = [ export default function Roadmap() { const { t } = useTranslation("roadmap"); - const router = useRouter(); - const { user, vip, loading, refetch } = useAuthAndVip(); - const [authOpen, setAuthOpen] = useState(false); return (
@@ -90,20 +84,8 @@ export default function Roadmap() { - +
- - setAuthOpen(false)} - onSuccess={async () => { - setAuthOpen(false); - const { vip: isVip } = await refetch(); - router.replace(isVip ? "/course" : "/join"); - }} - />
); diff --git a/app/components/course/CTASection.tsx b/app/components/course/CTASection.tsx index ca11a4b..5fda140 100644 --- a/app/components/course/CTASection.tsx +++ b/app/components/course/CTASection.tsx @@ -3,20 +3,79 @@ import { Link } from "@/i18n/navigation"; import { CTA_CONFIG } from "@/config/course"; import { useAuthAndVip } from "@/app/lib/useAuthAndVip"; +import { getStoredToken, getStoredUser } from "@/app/lib/pocketbase"; +import AuthModal from "@/app/components/AuthModal"; +import { + getPayEnv, + getRecommendedChannel, + mustUseWxPay, + redirectToPay, + getDeviceFromEnv, +} from "@/app/lib/payment"; +import { useState } from "react"; export function CTASection() { const { user, vip } = useAuthAndVip(); const paid = !!user && vip; + const [authOpen, setAuthOpen] = useState(false); + const [payRedirecting, setPayRedirecting] = useState(false); + + const startPay = (userId: string) => { + const env = getPayEnv(); + const channel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env); + const device = getDeviceFromEnv(env); + const returnUrl = `${window.location.origin}/course/0/0`; + + redirectToPay({ + user_id: userId, + return_url: returnUrl, + channel, + device, + useJoinConfig: true, + }); + }; + + const handleEnroll = async () => { + if (payRedirecting) return; + setPayRedirecting(true); + + try { + if (user?.id) { + startPay(user.id); + return; + } + + const meRes = await fetch("/api/auth/me", { credentials: "include" }); + const meData = await meRes.json().catch(() => ({})); + if (meData?.user?.id) { + startPay(meData.user.id); + return; + } + + const storedUser = getStoredUser(); + const storedToken = getStoredToken(); + if (storedUser?.id && storedToken) { + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: storedToken, record: storedUser }), + credentials: "include", + }).catch(() => null); + startPay(storedUser.id); + return; + } + + setAuthOpen(true); + } finally { + setPayRedirecting(false); + } + }; return (
-

- {CTA_CONFIG.title} -

-

- {CTA_CONFIG.subtitle} -

+

{CTA_CONFIG.title}

+

{CTA_CONFIG.subtitle}

{CTA_CONFIG.badges.map((b, i) => ( ) : ( - - 立即加入 → - + {payRedirecting ? "跳转支付中..." : "立即报名 →"} + )}
+ setAuthOpen(false)} + onSuccess={() => { + setAuthOpen(false); + void handleEnroll(); + }} + />
); } diff --git a/app/components/course/CourseHero.tsx b/app/components/course/CourseHero.tsx index fdd6b7a..8e1d9e1 100644 --- a/app/components/course/CourseHero.tsx +++ b/app/components/course/CourseHero.tsx @@ -4,7 +4,16 @@ import { useState, useEffect } from "react"; import { Link } from "@/i18n/navigation"; import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/course"; import { useAuthAndVip } from "@/app/lib/useAuthAndVip"; +import { getStoredToken, getStoredUser } from "@/app/lib/pocketbase"; import { getLastWatched, getProgress } from "@/app/lib/learning"; +import AuthModal from "@/app/components/AuthModal"; +import { + getPayEnv, + getRecommendedChannel, + mustUseWxPay, + redirectToPay, + getDeviceFromEnv, +} from "@/app/lib/payment"; const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0); @@ -12,6 +21,8 @@ export function CourseHero() { const { user, vip } = useAuthAndVip(); const [last, setLast] = useState>(null); const [progress, setProgress] = useState({ completed: 0, percent: 0 }); + const [authOpen, setAuthOpen] = useState(false); + const [payRedirecting, setPayRedirecting] = useState(false); useEffect(() => { setLast(getLastWatched()); @@ -26,6 +37,57 @@ export function CourseHero() { const paid = !!user && vip; + const startPay = (userId: string) => { + const env = getPayEnv(); + const channel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env); + const device = getDeviceFromEnv(env); + const returnUrl = `${window.location.origin}/course/0/0`; + + redirectToPay({ + user_id: userId, + return_url: returnUrl, + channel, + device, + useJoinConfig: true, + }); + }; + + const handleEnroll = async () => { + if (payRedirecting) return; + setPayRedirecting(true); + + try { + if (user?.id) { + startPay(user.id); + return; + } + + const meRes = await fetch("/api/auth/me", { credentials: "include" }); + const meData = await meRes.json().catch(() => ({})); + if (meData?.user?.id) { + startPay(meData.user.id); + return; + } + + const storedUser = getStoredUser(); + const storedToken = getStoredToken(); + if (storedUser?.id && storedToken) { + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: storedToken, record: storedUser }), + credentials: "include", + }).catch(() => null); + startPay(storedUser.id); + return; + } + + setAuthOpen(true); + } finally { + setPayRedirecting(false); + } + }; + return (
@@ -39,12 +101,8 @@ export function CourseHero() {
{HERO_CONFIG.stats.map((s) => (
-
- {s.value} -
-
- {s.label} -
+
{s.value}
+
{s.label}
))}
@@ -64,12 +122,14 @@ export function CourseHero() { 进入课程 → ) : ( - - 立即报名 → - + {payRedirecting ? "跳转支付中..." : "立即报名 →"} + )}
{paid && progress.completed > 0 && ( @@ -79,14 +139,19 @@ export function CourseHero() { {progress.completed}/{TOTAL_LESSONS} 节
-
+
)}
+ setAuthOpen(false)} + onSuccess={() => { + setAuthOpen(false); + void handleEnroll(); + }} + /> ); } diff --git a/app/layout.tsx b/app/layout.tsx index 29bafef..1cadf18 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -47,9 +47,7 @@ export default function RootLayout({ }>) { return ( - + {children} diff --git a/app/lib/auth-cookie.ts b/app/lib/auth-cookie.ts new file mode 100644 index 0000000..77df8ed --- /dev/null +++ b/app/lib/auth-cookie.ts @@ -0,0 +1,34 @@ +/** + * 认证 Cookie 配置 + * 生产环境设置 AUTH_COOKIE_DOMAIN 实现跨子域 SSO(如 .hackrobot.cn) + * 本地开发不设置 domain,避免 IP 访问时 cookie 失效 + */ + +const COOKIE_NAME = "pb_session"; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +export { COOKIE_NAME, COOKIE_MAX_AGE }; + +/** 从请求 Host 头提取 hostname(不含端口) */ +export function getHostFromRequest(request: { headers: { get: (k: string) => string | null } }): string { + const host = request.headers.get("host") || request.headers.get("x-forwarded-host") || ""; + return host.split(",")[0].trim().replace(/:\d+$/, ""); +} + +export function getCookieOptions(clear = false, requestHost?: string) { + const isProd = process.env.NODE_ENV === "production"; + const domain = process.env.AUTH_COOKIE_DOMAIN?.trim(); + const opts: Record = { + path: "/", + maxAge: clear ? 0 : COOKIE_MAX_AGE, + secure: isProd, + sameSite: "lax" as const, + httpOnly: true, + }; + if (domain && isProd) { + opts.domain = domain; + } else if (!isProd && requestHost && /^(\d{1,3}\.){3}\d{1,3}$/.test(requestHost)) { + opts.domain = requestHost; + } + return opts; +} diff --git a/app/lib/locale-link.tsx b/app/lib/locale-link.tsx index 065e279..e9c9355 100644 --- a/app/lib/locale-link.tsx +++ b/app/lib/locale-link.tsx @@ -9,9 +9,12 @@ type LocaleLinkProps = React.ComponentProps; export function LocaleLink({ href, ...props }: LocaleLinkProps) { const locale = useLocale(); const hrefStr = typeof href === "string" ? href : href.pathname ?? "/"; - const localeHref = hrefStr.startsWith("/") - ? `/${locale}${hrefStr === "/" ? "" : hrefStr}` - : hrefStr; + const localeHref = + hrefStr.startsWith("/api/") || hrefStr.startsWith("/_next") + ? hrefStr + : hrefStr.startsWith("/") + ? `/${locale}${hrefStr === "/" ? "" : hrefStr}` + : hrefStr; return ; } diff --git a/app/lib/useAuthAndVip.ts b/app/lib/useAuthAndVip.ts index 9c746e7..5423bdc 100644 --- a/app/lib/useAuthAndVip.ts +++ b/app/lib/useAuthAndVip.ts @@ -27,7 +27,6 @@ export function useAuthAndVip(): AuthAndVipState { let newUser = meData?.user ?? null; let newVip = vipData?.vip === true; - // 与 Header 一致:API 无用户时,尝试从 localStorage 恢复(如 cookie 未同步、localhost 开发等) if (!newUser) { const storedUser = getStoredUser(); const storedToken = getStoredToken(); diff --git a/app/lib/useCrossSiteUrls.ts b/app/lib/useCrossSiteUrls.ts new file mode 100644 index 0000000..2a64e1b --- /dev/null +++ b/app/lib/useCrossSiteUrls.ts @@ -0,0 +1,5 @@ +import { MEETUP_URL, VIP_URL } from "@/config/domain.config"; + +export function useCrossSiteUrls(): { meetupUrl: string; vipUrl: string } { + return { meetupUrl: MEETUP_URL, vipUrl: VIP_URL }; +} diff --git a/config/domain.config.ts b/config/domain.config.ts index 89c9ec3..6c3a771 100644 --- a/config/domain.config.ts +++ b/config/domain.config.ts @@ -16,22 +16,9 @@ const isDev = process.env.NODE_ENV === "development"; export const ROOT_DOMAIN = process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn"; -/** Cookie 根域,SSO 跨站登录用。如 .nomadro.com、.hackrobot.cn */ -export const AUTH_COOKIE_DOMAIN = - process.env.AUTH_COOKIE_DOMAIN || `.${ROOT_DOMAIN}`; - -/** 根据请求 host 返回 Cookie domain。localhost 时不设 domain,生产环境用根域实现跨站 SSO */ -export function getAuthCookieDomain(request: { headers: { get: (name: string) => string | null } }): string | undefined { - const envDomain = process.env.AUTH_COOKIE_DOMAIN; - if (envDomain) return envDomain; - const host = request.headers.get("host") ?? ""; - if (host.includes("localhost") || host.startsWith("127.0.0.1")) return undefined; - return AUTH_COOKIE_DOMAIN; -} - -/** 支付 API 默认地址 */ +/** 支付 API 默认地址(本地可用 PAYMENT_API_URL 覆盖,如 http://192.168.41.222:8007) */ export const DEFAULT_PAYMENT_API_URL = isDev - ? "http://127.0.0.1:8700" + ? "http://127.0.0.1:8007" : `https://api.${ROOT_DOMAIN}`; /** PocketBase 默认地址 */ @@ -44,3 +31,9 @@ export const DEFAULT_SITE_URL = isDev /** MinIO 端点默认主机 */ export const DEFAULT_MINIO_HOST = `minioweb.${ROOT_DOMAIN}`; + +/** meetup(目的地)站链接 */ +export const MEETUP_URL = `https://meetup.${ROOT_DOMAIN}/`; + +/** vip(异度星球)站链接 */ +export const VIP_URL = `https://vip.${ROOT_DOMAIN}/`; diff --git a/package.json b/package.json index a6365ce..b37f5cb 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3000 -H 0.0.0.0", "build": "next build", "start": "next start", "lint": "eslint" @@ -12,8 +12,6 @@ "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist-mono": "^5.2.7", "@aws-sdk/client-s3": "^3.1004.0", - "@fontsource-variable/geist": "^5.2.8", - "@fontsource-variable/geist-mono": "^5.2.7", "github-markdown-css": "^5.6.1", "highlight.js": "^11.11.1", "marked": "^14.1.2",