章节不存在
-- 请检查链接或返回课程首页 -
+请检查链接或返回课程首页
- ← 返回首页 + 返回首页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 (
- 请检查链接或返回课程首页 -
+请检查链接或返回课程首页
- ← 返回首页 + 返回首页- 时长 {lesson.duration} -
+时长 {lesson.duration}
- {/* 视频播放器 / 锁定 overlay */}报名解锁本章节
- - 立即报名 - + {payRedirecting ? "跳转支付中..." : "立即报名"} +- 课程配套资料已上传至网盘,报名后可前往下载 -
+课程配套资料已上传至网盘,报名后可前往下载
)} +- {mode === "login" ? "使用邮箱密码登录" : "创建新账号"} + {mode === "login" ? "Use email and password" : "Create a new account"}
{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