This commit is contained in:
eric
2026-03-12 04:25:12 -05:00
parent 528044db7d
commit 39a8358af1
7 changed files with 190 additions and 12 deletions

View File

@@ -59,6 +59,7 @@ async function createPayOrder(
) {
const payload: Record<string, unknown> = {
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,

View File

@@ -39,6 +39,31 @@ export async function pbLogin(
return { token: data.token, record: data.record };
}
/** 从 PocketBase 错误响应中提取可读错误信息 */
function parsePBError(err: { message?: string; data?: Record<string, { message?: string; code?: string }> }): 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<void> {
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 */
}

View File

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

View File

@@ -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) {

View File

@@ -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",
};
}

View File

@@ -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_idPocketBase 用户 ID
- site_iddigital | meetup | vip
- order_id支付订单号
- expires_at到期时间null 表示永久
---
## 四、cnomadcna 与 VIP 站关系
当前 cnomadcnameetup**不**支持 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

50
docs/CROSS_SITE_AUTH.md Normal file
View File

@@ -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 等跨域方案。