diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index c1500b1..b695561 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -59,6 +59,7 @@ async function createPayOrder( ) { const payload: Record = { user_id, + site_id: "vip", total_fee, type, channel, @@ -197,6 +198,7 @@ export async function POST(request: NextRequest) { // zpay:需要 redirect 获取 device 相关跳转 const redirectPayload = { user_id, + site_id: "vip", total_fee, type, channel, diff --git a/app/lib/pocketbase/auth.ts b/app/lib/pocketbase/auth.ts index f3c1c4e..97a6018 100644 --- a/app/lib/pocketbase/auth.ts +++ b/app/lib/pocketbase/auth.ts @@ -39,6 +39,31 @@ export async function pbLogin( return { token: data.token, record: data.record }; } +/** 从 PocketBase 错误响应中提取可读错误信息 */ +function parsePBError(err: { message?: string; data?: Record }): string { + const data = err?.data; + if (data && typeof data === "object" && Object.keys(data).length > 0) { + const first = Object.values(data)[0]; + if (first?.message) { + const code = first.code; + if (code === "validation_not_unique") { + return "该邮箱已被注册,请直接登录"; + } + if (code === "validation_required") { + return "请填写必填项"; + } + if (code?.startsWith("validation_")) { + return first.message; + } + return first.message; + } + } + if (err?.message === "Failed to create record.") { + return "注册失败,该邮箱可能已被使用,请尝试直接登录"; + } + return err?.message || "注册失败"; +} + export async function pbRegister( email: string, password: string, @@ -51,8 +76,8 @@ export async function pbRegister( body: JSON.stringify({ email, password, passwordConfirm }), }); if (!res.ok) { - const err = await res.json(); - throw new Error(err?.message || "注册失败"); + const err = await res.json().catch(() => ({})); + throw new Error(parsePBError(err)); } return pbLogin(email, password); } @@ -69,7 +94,7 @@ export async function pbLogout(): Promise { localStorage.removeItem(PB_STORAGE_KEYS.token); localStorage.removeItem(PB_STORAGE_KEYS.user); try { - await fetch("/api/auth/logout", { method: "POST" }); + await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); } catch { /* ignore */ } diff --git a/app/page.tsx b/app/page.tsx index bf1346f..02926f2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -170,9 +170,9 @@ export default function Home() { }, []); const getOrCreateUserId = useCallback(async () => { - // 优先使用 PocketBase 用户 ID(已登录时) + // 优先使用 PocketBase 用户 ID(已登录时,含跨站 Cookie) try { - const meRes = await fetch("/api/auth/me"); + const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" }); const meData = await meRes.json(); if (meData?.user?.id) { const pbUserId = meData.user.id; @@ -239,9 +239,10 @@ export default function Home() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: result.token, record: result.record }), + credentials: "include", }); const userId = result.record.id; - const vipRes = await fetch("/api/vip/check"); + const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); const vipData = await vipRes.json(); if (vipData?.vip) { setStorage("userId", userId); @@ -307,7 +308,7 @@ export default function Home() { const handlePay = useCallback(async () => { try { - const meRes = await fetch("/api/auth/me"); + const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" }); const meData = await meRes.json(); if (meData?.user?.id) { doPay(); @@ -368,16 +369,17 @@ export default function Home() { return; } - // SSO: 优先检查根域 Cookie(跨站登录态) + // SSO: 优先检查根域 Cookie(跨站登录态,如从 digital 登录后跳转过来) try { - const meRes = await fetch("/api/auth/me"); + const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" }); const meData = await meRes.json(); if (meData?.user && meData?.token) { const { pbSaveAuth } = await import("@/app/lib/pocketbase"); pbSaveAuth(meData.token, { id: meData.user.id, email: meData.user.email }); - const vipRes = await fetch("/api/vip/check"); + const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); const vipData = await vipRes.json(); if (vipData?.vip) { + setStorage("userId", meData.user.id); savePaidType("vip"); saveUserName(meData.user.email || meData.user.id); setLoading(false); diff --git a/app/vip/components/landing/AuthForm.tsx b/app/vip/components/landing/AuthForm.tsx index be18d9b..b5badbc 100644 --- a/app/vip/components/landing/AuthForm.tsx +++ b/app/vip/components/landing/AuthForm.tsx @@ -43,6 +43,7 @@ export function AuthForm({ onSuccess, autoFocus }: AuthFormProps) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: result.token, record: result.record }), + credentials: "include", }); onSuccess(); } catch (err) { diff --git a/config/services.config.ts b/config/services.config.ts index 7456780..03fc41a 100644 --- a/config/services.config.ts +++ b/config/services.config.ts @@ -16,7 +16,7 @@ export interface PaymentConfig { apiUrl: string; provider: "zpay" | "xorpay"; zpaySubmitUrl: string; - /** 默认金额(分),测试用1元 */ + /** 默认金额(分),88元=8800分 */ defaultAmount?: number; /** nomadvip 专用接口路径 */ nomadvipPayPath: string; @@ -59,7 +59,7 @@ export function getPaymentConfig(): PaymentConfig { provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay", zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php", - defaultAmount: 100, + defaultAmount: 8800, nomadvipPayPath: "/nomadvip/payh5", }; } diff --git a/docs/AUTH_VIP_CHECK_REPORT.md b/docs/AUTH_VIP_CHECK_REPORT.md new file mode 100644 index 0000000..34734e3 --- /dev/null +++ b/docs/AUTH_VIP_CHECK_REPORT.md @@ -0,0 +1,98 @@ +# 登录态共享与 VIP 解锁逻辑检查报告 + +检查范围:digital、cnomadcna、nomadvip、payjsapi 后端 + +--- + +## 一、登录态共享(SSO) + +### 1.1 Cookie 配置 + +| 项目 | Cookie 名 | 域名 | 配置来源 | +|------|-----------|------|----------| +| nomadvip | pb_session | AUTH_COOKIE_DOMAIN \|\| .hackrobot.cn | 硬编码 | +| digital | pb_session | AUTH_COOKIE_DOMAIN (domain.config) | .${ROOT_DOMAIN} | +| cnomadcna | pb_session | AUTH_COOKIE_DOMAIN (domain.config) | .${ROOT_DOMAIN} | + +**结论**:三项目 Cookie 配置一致,默认均为 `.hackrobot.cn`。 + +### 1.2 Auth API 实现 + +| 项目 | /api/auth/sync-session | /api/auth/me | /api/auth/logout | +|------|------------------------|--------------|------------------| +| nomadvip | ✅ | ✅ | ✅ | +| digital | ✅ | ✅ | ✅ | +| cnomadcna | ✅ | ✅ | ✅ | + +### 1.3 已修复项 + +- **digital/cnomadcna AuthModal**:sync-session 请求添加 `credentials: "include"` +- **digital Header / cnomadcna Navbar**:/api/auth/me 添加 `credentials: "include"` +- **nomadvip**:此前已添加 credentials + +--- + +## 二、VIP 解锁逻辑 + +### 2.1 site_id 与 VIP 校验 + +| 项目 | SITE_ID | VIP 校验逻辑 | +|------|---------|--------------| +| nomadvip | vip | site_vip.site_id = "vip" | +| digital | digital(可配置) | site_id = SITE_ID **或** site_id = "vip"(VIP 站可解锁所有 digital) | +| cnomadcna | meetup | site_vip.site_id = "meetup"(仅本站) | + +### 2.2 支付接口 site_id 传递 + +| 项目 | 接口路径 | site_id 传递 | 状态 | +|------|----------|--------------|------| +| digital | /digital/payh5 | ✅ site_id: SITE_ID | 正常 | +| cnomadcna | /cnomadcna/payh5 | ✅ site_id: "meetup" | 正常 | +| nomadvip | /nomadvip/payh5 | ✅ site_id: "vip"(已修复) | 已修复 | + +**nomadvip 修复**:createPayOrder 及 redirect 请求中补充 `site_id: "vip"`,确保 payjsapi 支付回调能正确写入 site_vip。 + +--- + +## 三、payjsapi 后端要求 + +payjsapi 不在本仓库,需在 api.hackrobot.cn 侧确认: + +### 3.1 订单创建接口 + +- **nomadvip**:`POST /nomadvip/payh5` 需接收 `site_id: "vip"` +- **digital**:`POST /digital/payh5` 需接收 `site_id` +- **cnomadcna**:`POST /cnomadcna/payh5` 需接收 `site_id: "meetup"` + +### 3.2 支付成功回调 + +支付成功后,payjsapi 需: + +1. 从订单中读取 `user_id`、`site_id` +2. 调用 PocketBase 或内部 API 写入/更新 `site_vip` 表 +3. 字段:user_id、site_id、order_id、expires_at(可选) + +### 3.3 PocketBase site_vip 表 + +- user_id:PocketBase 用户 ID +- site_id:digital | meetup | vip +- order_id:支付订单号 +- expires_at:到期时间,null 表示永久 + +--- + +## 四、cnomadcna 与 VIP 站关系 + +当前 cnomadcna(meetup)**不**支持 VIP 站解锁,仅校验 `site_id = "meetup"`。 + +若需「VIP 站付费可解锁 meetup」,需在 cnomadcna 的 vip/check 中增加对 `site_id = "vip"` 的校验,与 digital 保持一致。 + +--- + +## 五、检查清单 + +- [x] nomadvip 支付传递 site_id +- [x] digital/cnomadcna sync-session 使用 credentials +- [x] digital/cnomadcna auth/me 使用 credentials +- [ ] payjsapi 确认 nomadvip 订单接收并处理 site_id +- [ ] payjsapi 确认支付回调写入 site_vip diff --git a/docs/CROSS_SITE_AUTH.md b/docs/CROSS_SITE_AUTH.md new file mode 100644 index 0000000..6f158f2 --- /dev/null +++ b/docs/CROSS_SITE_AUTH.md @@ -0,0 +1,50 @@ +# 跨站登录态同步说明 + +## 原理 + +VIP 站点在用户注册/登录时,通过 `/api/auth/sync-session` 设置 Cookie `pb_session`,域名为 `.hackrobot.cn`。 +**浏览器会自动将该 Cookie 发送到所有 `*.hackrobot.cn` 子域**(如 digital.hackrobot.cn、meetup.hackrobot.cn),**无需从 VIP 跳转**。 + +## 当前流程 + +1. 用户在 vip.hackrobot.cn 点击「立即加入」→ 注册/登录 +2. AuthForm 调用 `sync-session`,服务端设置 `pb_session`(domain=.hackrobot.cn) +3. 用户访问 digital.hackrobot.cn 或 meetup.hackrobot.cn 时,浏览器会自动带上该 Cookie + +## digital / meetup 需具备的能力 + +要让 digital、meetup 显示已登录,它们需要: + +1. **提供 `/api/auth/me` 接口**:读取请求中的 `pb_session` Cookie,校验 token 并返回用户信息 +2. **前端调用该接口**:页面加载时请求 `/api/auth/me`,根据返回结果展示登录态 + +### 接口规范(与 nomadvip 保持一致) + +- **Cookie 名**:`pb_session` +- **Cookie 域**:`.hackrobot.cn`(环境变量 `AUTH_COOKIE_DOMAIN`) +- **内容**:base64url 编码的 `{ token, userId, email }` + +### /api/auth/me 实现参考 + +可参考 `nomadvip/app/api/auth/me/route.ts`,逻辑为: + +1. 读取 `pb_session` Cookie +2. 解码得到 token +3. 调用 PocketBase `api/users/refresh` 校验 token +4. 返回 `{ user: { id, email } }` 或 `{ user: null }` + +## 常见问题 + +### 1. 刷新 digital/meetup 仍显示未登录 + +- 检查 digital/meetup 是否实现了 `/api/auth/me` 并读取 `pb_session` +- 检查是否在同一根域下(如均为 `*.hackrobot.cn`) +- 检查 `AUTH_COOKIE_DOMAIN` 是否配置为 `.hackrobot.cn` + +### 2. 是否必须从 VIP 跳转过去? + +**不需要**。Cookie 由浏览器按域名自动管理,只要 Cookie 已设置,访问任意 `*.hackrobot.cn` 时都会自动带上。 + +### 3. 不同根域(如 digital.com)能否共享? + +不能。Cookie 的 `domain` 只能作用于当前域及其子域,无法跨根域共享。若 digital、meetup 与 VIP 不在同一根域,需改用 OAuth、JWT 等跨域方案。