10 Commits
dev ... test2

Author SHA1 Message Date
eric
f8da75fad2 's' 2026-06-16 09:46:49 -05:00
eric
c1014909ac 's' 2026-03-23 23:02:45 -05:00
eric
0528b1853a 's' 2026-03-23 22:59:42 -05:00
eric
614f001986 'env' 2026-03-13 10:58:35 -05:00
eric
28c21522e3 's' 2026-03-13 06:17:48 -05:00
eric
c1eb0bbcf3 's' 2026-03-13 05:51:05 -05:00
eric
2365bb9e17 'c1' 2026-03-13 05:15:33 -05:00
eric
a193fe4d45 s'c1' 2026-03-13 04:41:51 -05:00
eric
37d0883ce9 '' 2026-03-12 19:50:25 -05:00
eric
ffa5043daf ;s; 2026-03-12 05:40:18 -05:00
50 changed files with 2640 additions and 697 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# ========== 与 payjsapi/cnomadcna/nomadvip 同源 ==========
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn
PAYMENT_API_URL=http://127.0.0.1:8007
PAYMENT_PROVIDER=zpay
PAYMENT_DEFAULT_AMOUNT=100
NEXT_PUBLIC_SITE_ID=digital
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# 与 payjsapi 同源
POCKETBASE_EMAIL=xiaoshuang.eric@gmail.com
POCKETBASE_PASSWORD=Xiao4669805@
# ========== 生产环境 ==========
# ROOT_DOMAIN=hackrobot.cn
# AUTH_COOKIE_DOMAIN=.hackrobot.cn
# PAYMENT_API_URL=https://api.hackrobot.cn
# NEXT_PUBLIC_SITE_URL=https://digital.hackrobot.cn

11
.env.local Normal file
View File

@@ -0,0 +1,11 @@
# 本地调试 - 支付测试 1 元、zpay 渠道
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn
PAYMENT_API_URL=http://127.0.0.1:8007
PAYMENT_PROVIDER=zpay
PAYMENT_DEFAULT_AMOUNT=100
NEXT_PUBLIC_SITE_ID=digital
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# 与 payjsapi 同源(/api/vip/check 等需要)
POCKETBASE_EMAIL=xiaoshuang.eric@gmail.com
POCKETBASE_PASSWORD=Xiao4669805@

6
.gitignore vendored
View File

@@ -14,8 +14,8 @@
/coverage
# next.js
/.next/
/out/
.next/
out/
# production
/build
@@ -31,7 +31,7 @@ yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# .env*
# vercel
.vercel

View File

@@ -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 未同步 localStorageNavbar 不刷新 | 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` cookiedigital 的 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` 隐藏「正在跳转」提示,直接进入支付流程

View File

@@ -6,29 +6,101 @@ import { Link, useTranslation } from "@/i18n/navigation";
import { VideoPlayer } from "@/app/components/course/VideoPlayer";
import { LessonMarkdown } from "./LessonMarkdown";
import { getLesson } from "@/config/lessons";
import { canWatchLesson } from "@/app/lib/course-payment";
import { canWatchLesson, isFreeTrial } 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,
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, refetch } = 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);
const lesson = getLesson(moduleIndex, lessonIndex);
const unlocked = canWatchLesson(moduleIndex, lessonIndex, vip);
const isFreeTrial = moduleIndex === 0 && lessonIndex < 2;
useEffect(() => {
if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
}, [moduleIndex, lessonIndex, lesson?.name, unlocked]);
const startPay = (userId: string) => {
const env = getPayEnv();
const channel = "wxpay";
const device = getDeviceFromEnv(env);
const returnUrl = `${window.location.origin}/`;
redirectToPay({
user_id: userId,
return_url: returnUrl,
channel,
device,
type: "video",
useJoinConfig: true,
});
};
const handleEnroll = async () => {
if (payRedirecting) return;
setPayRedirecting(true);
try {
// 先验证登录态
let meRes = await fetch("/api/auth/me", { credentials: "include" });
let meData = await meRes.json().catch(() => ({}));
if (meData?.user?.id) {
// 已登录:先校验 VIP已是 VIP 则无需支付
const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
const vipData = await vipRes.json().catch(() => ({}));
if (vipData?.vip) {
await refetch();
return;
}
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);
meRes = await fetch("/api/auth/me", { credentials: "include" });
meData = await meRes.json().catch(() => ({}));
if (meData?.user?.id) {
const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
const vipData = await vipRes.json().catch(() => ({}));
if (vipData?.vip) {
await refetch();
return;
}
startPay(meData.user.id);
return;
}
}
setAuthOpen(true);
} finally {
setPayRedirecting(false);
}
};
if (!lesson || isNaN(moduleIndex) || isNaN(lessonIndex)) {
return (
<div className="min-h-screen bg-[var(--background)]">
@@ -36,14 +108,12 @@ export default function LessonPage() {
<main className="pt-20 pb-16">
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
<h1 className="mb-4 text-2xl font-bold"></h1>
<p className="mb-6 text-[var(--muted-foreground)]">
</p>
<p className="mb-6 text-[var(--muted-foreground)]"></p>
<Link
href="/course"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
</Link>
</div>
</main>
@@ -56,7 +126,6 @@ export default function LessonPage() {
<Header />
<main className="pt-14 pb-16 md:pt-16">
<div className="mx-auto max-w-4xl px-4 sm:px-6">
{/* 返回导航 */}
<nav className="mb-6 flex items-center gap-2 text-sm">
<button
type="button"
@@ -74,20 +143,16 @@ export default function LessonPage() {
</Link>
</nav>
{/* 标题 */}
<h1 className="mb-2 flex items-center gap-2 text-xl font-bold sm:text-2xl md:text-3xl">
{lesson.name}
{isFreeTrial && (
{isFreeTrial(moduleIndex, lessonIndex) && (
<span className="rounded bg-[var(--accent)]/20 px-2 py-0.5 text-sm font-normal text-[var(--accent)]">
</span>
)}
</h1>
<p className="mb-8 text-sm text-[var(--muted-foreground)]">
{lesson.duration}
</p>
<p className="mb-8 text-sm text-[var(--muted-foreground)]"> {lesson.duration}</p>
{/* 视频播放器 / 锁定 overlay */}
<div className="relative mb-12">
{unlocked ? (
<VideoPlayer
@@ -102,18 +167,19 @@ export default function LessonPage() {
<div className="aspect-video flex flex-col items-center justify-center gap-4 bg-zinc-900">
<span className="text-6xl">🔒</span>
<p className="text-lg text-white/90"></p>
<Link
href="/join"
className="rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
<button
type="button"
onClick={handleEnroll}
disabled={payRedirecting}
className="rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90 disabled:opacity-70"
>
</Link>
{payRedirecting ? "跳转支付中..." : "立即报名"}
</button>
</div>
</div>
)}
</div>
{/* 配套文档 - 仅解锁后显示 */}
{unlocked && (
<div className="relative rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
<h2 className="mb-6 flex items-center gap-2 text-lg font-semibold">
@@ -126,7 +192,6 @@ export default function LessonPage() {
</div>
)}
{/* 网盘下载 */}
{DOWNLOAD_CONFIG.enabled && unlocked && (
<div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
@@ -135,9 +200,7 @@ export default function LessonPage() {
</svg>
{DOWNLOAD_CONFIG.title}
</h2>
<p className="mb-4 text-sm text-[var(--muted-foreground)]">
</p>
<p className="mb-4 text-sm text-[var(--muted-foreground)]"></p>
<a
href={DOWNLOAD_CONFIG.url}
target="_blank"
@@ -161,6 +224,14 @@ export default function LessonPage() {
{t("updating")}
</div>
)}
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={() => {
setAuthOpen(false);
void refetch();
}}
/>
</div>
);
}

View File

@@ -4,13 +4,10 @@ import { useState, useRef, useEffect, useCallback, type FormEvent, type ChangeEv
import { Link } from "@/i18n/navigation";
import {
getPayEnv,
getRecommendedChannel,
mustUseWxPay,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
import type { PayChannel, PayEnv } from "@/app/lib/payment";
import type { PayEnv } from "@/app/lib/payment";
import { getStoredToken } from "@/app/lib/pocketbase";
import AuthModal from "@/app/components/AuthModal";
@@ -57,7 +54,6 @@ export default function JoinPage() {
const [error, setError] = useState<string | null>(null);
const [payRedirecting, setPayRedirecting] = useState(false);
const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
const [payChannel, setPayChannel] = useState<PayChannel>("alipay");
const [authOpen, setAuthOpen] = useState(false);
const checkAuth = useCallback(async () => {
@@ -77,14 +73,10 @@ export default function JoinPage() {
}
}, []);
// 微信 H5 支付完成后可能不跳转,后台静默轮询,检测到已支付则显示成功页
usePayStatusPoll(() => setSubmitted(true));
useEffect(() => {
if (typeof window === "undefined") return;
const env = getPayEnv();
setPayEnv(env);
setPayChannel(getRecommendedChannel(env));
}, []);
const set = (key: keyof FormData, value: string) =>
@@ -118,9 +110,9 @@ export default function JoinPage() {
setSubmitting(false);
setPayRedirecting(true);
// Z-Pay return_url 不支持带参数,使用 /join/paid 专用路径
const returnUrl = `${window.location.origin}${window.location.pathname}/paid`;
const returnUrl = `${window.location.origin}/`;
const env = getPayEnv();
const channel = mustUseWxPay(env) ? "wxpay" : payChannel;
const channel = "wxpay";
const device = getDeviceFromEnv(env);
redirectToPay({
user_id: userId,
@@ -463,39 +455,12 @@ export default function JoinPage() {
)}
</div>
{/* ── 支付通道选择PC/H5 可选,微信内强制微信支付)── */}
{/* ── 支付方式:默认微信支付,支付宝暂不提供 ── */}
<div className="mt-6 rounded-xl border border-slate-100 bg-slate-50/50 px-4 py-4 sm:px-5">
<p className="mb-3 text-sm font-bold text-slate-700">💳 </p>
{payEnv === "wechat" ? (
<p className="text-sm text-slate-500">
使 <span className="font-medium text-emerald-600"></span>
</p>
) : (
<div className="flex gap-3">
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-sky-500 has-[:checked]:bg-sky-50">
<input
type="radio"
name="payChannel"
value="alipay"
checked={payChannel === "alipay"}
onChange={() => setPayChannel("alipay")}
className="h-4 w-4 border-slate-300 text-sky-500"
/>
<span className="text-sm font-medium text-slate-700"></span>
</label>
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-emerald-500 has-[:checked]:bg-emerald-50">
<input
type="radio"
name="payChannel"
value="wxpay"
checked={payChannel === "wxpay"}
onChange={() => setPayChannel("wxpay")}
className="h-4 w-4 border-slate-300 text-emerald-500"
/>
<span className="text-sm font-medium text-slate-700"></span>
</label>
</div>
)}
<p className="text-sm text-slate-500">
使 <span className="font-medium text-emerald-600"></span>
</p>
</div>
{/* ── Submit ── */}

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import { Link } from "@/i18n/navigation";
/**
@@ -7,6 +8,13 @@ import { Link } from "@/i18n/navigation";
* Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
*/
export default function JoinPaidPage() {
useEffect(() => {
try {
localStorage.removeItem("join_pay_order");
} catch {}
document.cookie = "join_pay_order=; path=/; max-age=0";
}, []);
return (
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] px-4">
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white p-10 text-center shadow-lg">

View File

@@ -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<string, string | number | boolean>);
return res;
}

View File

@@ -1,10 +1,8 @@
import { NextResponse } from "next/server";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
import { NextRequest, NextResponse } from "next/server";
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
const COOKIE_NAME = "pb_session";
export async function POST() {
export async function POST(request: NextRequest) {
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 });
res.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record<string, string | number | boolean>);
return res;
}

View File

@@ -1,9 +1,6 @@
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;
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
export async function GET(request: NextRequest) {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
@@ -16,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: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 });
r.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record<string, string | number | boolean>);
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,
@@ -40,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: AUTH_COOKIE_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<string, string | number | boolean>);
return r;
}
return NextResponse.json({

View File

@@ -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<string, string | number | boolean>);
return res;
}

View File

@@ -1,8 +1,5 @@
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 天,登录态持续有效直至用户主动退出
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
@@ -20,13 +17,6 @@ export async function POST(request: NextRequest) {
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,
});
res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record<string, string | number | boolean>);
return res;
}

View File

@@ -0,0 +1,169 @@
/**
* 支付确认:对齐 nomadvip 标准。payjsapi 异步通知未到达时,由前端直接写入 PocketBase site_vip
*/
import { NextRequest, NextResponse } from "next/server";
import {
getApiUrlFromRequest,
getPocketBaseConfig,
getPaymentConfig,
getJoinPaymentConfig,
SITE_ID,
} from "@/config/services.config";
import { queryXorPayOrderStatus } from "@/app/lib/payment/xorpayStatus";
import { queryZPayOrderStatus } from "@/app/lib/payment/zpayStatus";
function parseUserIdFromOrderId(orderId: string): string | null {
if (!orderId || !orderId.includes("_order_")) return null;
const prefix = orderId.split("_order_")[0];
if (!prefix || !prefix.includes("_")) return prefix || null;
const parts = prefix.split("_");
const type = parts[parts.length - 1];
if (["vip", "video", "meetup", "book"].includes(type?.toLowerCase() || "")) {
return parts.slice(0, -1).join("_") || null;
}
return prefix;
}
async function getAdminToken(): Promise<string | null> {
const email = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL;
const password = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_PASSWORD;
if (!email || !password) return null;
const pb = getPocketBaseConfig();
const paths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"];
for (const path of paths) {
const res = await fetch(`${pb.url}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (res.ok) {
const data = await res.json();
if (data?.token) return data.token;
}
}
return null;
}
function resolvePayApiUrl(request: NextRequest): string {
const payConfig = getPaymentConfig();
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
return (
(hostHeader ? getApiUrlFromRequest(hostHeader) : null) ||
payConfig.apiUrl
).replace(/\/$/, "");
}
async function verifyOrderPaid(request: NextRequest, orderId: string): Promise<boolean> {
const apiUrl = resolvePayApiUrl(request);
const url = `${apiUrl}/digital/order_status?order_id=${encodeURIComponent(orderId)}`;
try {
const res = await fetch(url, { cache: "no-store" });
const data = await res.json().catch(() => ({}));
if (data?.paid) return true;
} catch (e) {
console.error("[pay/confirm] verifyOrderPaid fetch error:", e);
}
if (await queryXorPayOrderStatus(orderId, 5000).then((r) => r?.paid)) return true;
if (await queryZPayOrderStatus(orderId, 5000).then((r) => r?.paid)) return true;
return false;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
let orderId = String(body?.order_id ?? body?.orderId ?? "").trim();
let userId = String(body?.user_id ?? body?.userId ?? "").trim();
if (!orderId) {
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
}
if (!userId) {
userId = parseUserIdFromOrderId(orderId) || "";
}
if (!userId) {
return NextResponse.json({ ok: false, error: "缺少 user_id 且无法从 order_id 解析" }, { status: 400 });
}
const paid = await verifyOrderPaid(request, orderId);
if (!paid) {
return NextResponse.json({ ok: false, error: "订单未支付" }, { status: 400 });
}
const adminToken = await getAdminToken();
if (!adminToken) {
return NextResponse.json({ ok: false, error: "PocketBase 未配置" }, { status: 500 });
}
const pb = getPocketBaseConfig();
const joinConfig = getJoinPaymentConfig();
const totalFee = Number(joinConfig?.amount ?? 8800);
const checkPayments = await fetch(
`${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(`order_id="${orderId}"`)}&perPage=1`,
{ headers: { Authorization: `Bearer ${adminToken}` } }
);
const paymentsData = await checkPayments.json().catch(() => ({}));
if ((paymentsData?.items?.length ?? 0) === 0) {
await fetch(`${pb.url}/api/collections/payments/records`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify({
user_id: userId,
type: "video",
order_id: orderId,
total_fee: totalFee,
amount: totalFee,
channel: "wxpay",
}),
});
}
const siteVipCheck = await fetch(
`${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(`user_id="${userId}" && site_id="${SITE_ID}"`)}&perPage=1`,
{ headers: { Authorization: `Bearer ${adminToken}` } }
);
const siteVipData = await siteVipCheck.json().catch(() => ({}));
const siteVipItems = siteVipData?.items ?? [];
if (siteVipItems.length > 0) {
await fetch(`${pb.url}/api/collections/site_vip/records/${siteVipItems[0].id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify({ order_id: orderId }),
});
} else {
const siteVipRes = await fetch(`${pb.url}/api/collections/site_vip/records`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify({
user_id: userId,
site_id: SITE_ID,
order_id: orderId,
}),
});
if (!siteVipRes.ok) {
const err = await siteVipRes.json().catch(() => ({}));
if (!err?.data?.order_id?.message?.includes("unique") && !err?.message?.includes("unique")) {
console.error("[pay/confirm] site_vip create failed:", await siteVipRes.text());
}
}
}
return NextResponse.json({ ok: true });
} catch (e) {
console.error("[pay/confirm] error:", e);
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : "确认失败" },
{ status: 500 }
);
}
}

View File

@@ -1,57 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { getPaymentConfig, getJoinPaymentConfig, SITE_ID } from "@/config/services.config";
import {
getApiUrlFromRequest,
getJoinPaymentConfig,
getPaymentConfig,
SITE_ID,
} from "@/config/services.config";
import { SITE_URL } from "../../lib/env";
async function createPayOrder(
user_id: string,
return_url: string,
total_fee?: number,
type?: string,
channel = "alipay"
) {
const joinConfig = getJoinPaymentConfig();
const fee = total_fee ?? joinConfig.amount;
const orderType = type ?? joinConfig.type;
const ORDER_COOKIE = "join_pay_order";
function resolvePayApiUrl(request: NextRequest): string {
const config = getPaymentConfig();
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
const payload = {
user_id,
site_id: SITE_ID,
total_fee: Number(fee) || joinConfig.amount,
type: orderType,
channel,
return_url,
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(`${config.apiUrl}/digital/payh5`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.status !== "ok") {
throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
}
let params = data.params || data.xorpay_params || {};
const payUrl = data.pay_url || data.xorpay_url || config.zpaySubmitUrl;
if (!params || typeof params !== "object") {
throw new Error("payjsapi 未返回支付参数");
}
params = JSON.parse(JSON.stringify(params));
if (data.provider === "xorpay") {
throw new Error("当前 payjsapi 使用 xorpay请设置 PAYMENT_PROVIDER=zpay 后重启 payjsapi");
}
return { params, payUrl };
const hostHeader =
request.headers.get("host") || request.headers.get("x-forwarded-host");
return (getApiUrlFromRequest(hostHeader) || config.apiUrl).replace(/\/$/, "");
}
const ZPAY_REQUIRED = ["pid", "type", "out_trade_no", "notify_url", "return_url", "name", "money", "sign", "sign_type"];
function setPayOrderCookie(response: NextResponse, orderId: string) {
response.cookies.set(ORDER_COOKIE, `${orderId}|${Date.now()}`, {
path: "/",
maxAge: 900,
});
}
function escapeAttr(s: string): string {
return String(s)
@@ -61,39 +31,236 @@ function escapeAttr(s: string): string {
.replace(/>/g, "&gt;");
}
function buildPayHtml(payUrl: string, params: Record<string, unknown>): string {
const flat: Record<string, string> = {};
for (const [k, v] of Object.entries(params)) {
if (v == null) continue;
const s = String(v).trim();
if (s === "") continue;
flat[k] = s;
}
for (const key of ZPAY_REQUIRED) {
if (!flat[key]) {
console.error(`ZPAY 缺少必填参数: ${key}`, flat);
}
}
const inputs = Object.entries(flat)
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
function buildPayHtml(
payUrl: string,
params: Record<string, unknown>,
options?: { directToCashier?: boolean; orderId?: string }
): string {
const inputs = Object.entries(params)
.filter(([, value]) => value != null && String(value).trim() !== "")
.map(
([key, value]) =>
`<input type="hidden" name="${escapeAttr(key)}" value="${escapeAttr(
String(value)
)}" />`
)
.join("\n");
const pendingOrderScript = options?.orderId
? `<script>(function(){var v=${JSON.stringify(
`${options.orderId}|${Date.now()}`
)};try{localStorage.setItem("join_pay_order",v);}catch(e){}document.cookie="join_pay_order="+encodeURIComponent(v)+"; path=/; max-age=900";})();</script>`
: "";
return options?.directToCashier
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>支付</title><style>body{margin:0;overflow:hidden}</style></head><body><form id="f" action="${escapeAttr(
payUrl
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`
: `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style></head><body><p>正在跳转到支付页面...</p><form id="f" action="${escapeAttr(
payUrl
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`;
}
function buildQrHtml(
imgSrc: string,
returnUrl: string,
orderId: string,
opts?: { channel?: string; successDesc?: string; confirmUrl?: string }
): string {
const ch = (opts?.channel || "wxpay").toLowerCase();
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
const successDesc = opts?.successDesc || "支付成功,即将跳转";
const confirmUrl = opts?.confirmUrl || "/api/pay/confirm";
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>跳转支付...</title>
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style>
</head>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<style>
*{box-sizing:border-box;}
body{font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);}
.card{background:#fff;border-radius:20px;padding:40px;box-shadow:0 25px 50px -12px rgba(0,0,0,.25);max-width:420px;}
h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
.hint{color:#64748b;font-size:14px;margin-bottom:20px;}
.wait-row{display:flex;align-items:center;justify-content:center;gap:24px;margin:20px 0;}
.qr-wrap img{width:200px;height:200px;border-radius:12px;border:2px solid #e2e8f0;background:#fff;}
.countdown-wrap{width:72px;height:72px;flex-shrink:0;position:relative;}
.countdown-ring{transform:rotate(-90deg);}
.countdown-ring circle{fill:none;stroke-width:5;transition:stroke-dashoffset .5s linear;}
.countdown-ring .bg{stroke:#e2e8f0;}
.countdown-ring .progress{stroke:#22c55e;stroke-linecap:round;}
.countdown-inner{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;}
.countdown-num{font-size:1rem;font-weight:700;color:#1e293b;line-height:1;}
.countdown-unit{font-size:9px;color:#94a3b8;}
.tip{font-size:13px;color:#64748b;margin-top:16px;}
.success-wrap{display:none;}
.success-wrap.show{display:block;}
.wait-wrap.hide{display:none;}
.success-icon{font-size:4rem;margin-bottom:16px;animation:scaleIn .4s ease;}
.success-title{font-size:1.25rem;color:#16a34a;font-weight:600;margin-bottom:8px;}
.success-desc{color:#64748b;font-size:14px;margin-bottom:24px;}
.success-countdown{font-size:1.5rem;font-weight:700;color:#667eea;}
@keyframes scaleIn{from{transform:scale(0);opacity:0;}to{transform:scale(1);opacity:1;}}
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.7;}}
.pulse{animation:pulse 2s ease-in-out infinite;}
</style></head>
<body>
<p>正在跳转到支付页面...</p>
<form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">
${inputs}
</form>
<script>document.getElementById("zpayForm").submit();</script>
</body>
</html>`;
<div class="card">
<div class="wait-wrap" id="waitWrap">
<h2>${esc(title)}</h2>
<p class="hint">${esc(hint)}</p>
<div class="wait-row">
<div class="countdown-wrap">
<svg class="countdown-ring" width="72" height="72" viewBox="0 0 72 72">
<circle class="bg" cx="36" cy="36" r="32"/>
<circle class="progress" id="progressCircle" cx="36" cy="36" r="32" stroke-dasharray="201" stroke-dashoffset="0"/>
</svg>
<div class="countdown-inner">
<span class="countdown-num" id="countdownNum">5:00</span>
<span class="countdown-unit">分</span>
</div>
</div>
<div class="qr-wrap">
<img src="${imgSrc}" alt="支付二维码" />
</div>
</div>
<p class="tip pulse" id="tip">等待支付中...</p>
</div>
<div class="success-wrap" id="successWrap">
<div class="success-icon">✅</div>
<div class="success-title">支付成功</div>
<div class="success-desc">${esc(successDesc)}</div>
<div class="success-countdown" id="successCountdown">3 秒后跳转</div>
</div>
</div>
<script>
(function(){
try{localStorage.setItem("join_pay_order",${JSON.stringify(`${orderId}|${Date.now()}`)});}catch(e){}
var orderId=${JSON.stringify(orderId)};
var returnUrl=${JSON.stringify(returnUrl)};
if(!orderId){return;}
var waitWrap=document.getElementById("waitWrap");
var successWrap=document.getElementById("successWrap");
var tip=document.getElementById("tip");
var countdownNum=document.getElementById("countdownNum");
var progressCircle=document.getElementById("progressCircle");
var successCountdown=document.getElementById("successCountdown");
var maxT=300,left=maxT;
var circumference=2*Math.PI*32;
function fmt(t){var m=Math.floor(t/60),s=t%60;return m+":"+(s<10?"0":"")+s;}
var countdownTimer=setInterval(function(){
left--;
countdownNum.textContent=left>60?fmt(left):left;
progressCircle.style.strokeDashoffset=circumference*(1-left/maxT);
if(left<=0){clearInterval(countdownTimer);tip.textContent="支付超时,返回首页";setTimeout(function(){window.location.href="/";},800);}
},1000);
function showSuccess(){
clearInterval(countdownTimer);
waitWrap.classList.add("hide");
successWrap.classList.add("show");
var s=3;
var t=setInterval(function(){
s--;
successCountdown.textContent=s>0?s+" 秒后跳转":"跳转中…";
if(s<=0){clearInterval(t);window.location.href=returnUrl;}
},1000);
}
var confirmUrl=${JSON.stringify(confirmUrl)};
function doConfirm(attempt){
if(attempt>=3){showSuccess();return;}
fetch(confirmUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({order_id:orderId})})
.then(function(r){return r.json();})
.then(function(cd){
if(cd&&cd.ok){showSuccess();}
else{setTimeout(function(){doConfirm(attempt+1);},800);}
})
.catch(function(){setTimeout(function(){doConfirm(attempt+1);},800);});
}
function check(){
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId),{cache:"no-store"})
.then(function(r){return r.json();})
.then(function(d){
if(d.ok&&d.paid){doConfirm(0);return;}
setTimeout(check,500);
})
.catch(function(){setTimeout(check,500);});
}
setTimeout(check,500);
})();
</script>
</body></html>`;
}
async function createPayOrder(
apiUrl: string,
userId: string,
returnUrl: string,
totalFee: number,
type: string,
channel: string,
provider?: "xorpay",
clientIp?: string
) {
const payload: Record<string, unknown> = {
user_id: userId,
site_id: SITE_ID,
total_fee: totalFee,
type,
channel,
return_url: returnUrl,
...(provider ? { provider } : {}),
...(clientIp ? { client_ip: clientIp } : {}),
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(`${apiUrl}/digital/payh5`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.status !== "ok") {
console.error("[pay] createPayOrder failed status=%s body=%j", res.status, data);
throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
}
return {
params: (data.params ?? data.xorpay_params ?? {}) as Record<string, string>,
payUrl:
data.pay_url || data.xorpay_url || getPaymentConfig().zpaySubmitUrl,
provider: String(data.provider || (provider ? "xorpay" : "zpay")).trim(),
orderId: String(
data.order_id ||
data.params?.out_trade_no ||
data.xorpay_params?.order_id ||
""
).trim(),
};
}
async function requestRedirect(
apiUrl: string,
path: string,
payload: Record<string, unknown>
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
try {
return await fetch(`${apiUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
redirect: "manual",
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
export async function POST(request: NextRequest) {
@@ -101,7 +268,7 @@ export async function POST(request: NextRequest) {
let body: Record<string, string | number>;
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
body = await request.json();
body = (await request.json()) as Record<string, string | number>;
} else {
const form = await request.formData();
body = Object.fromEntries(form.entries()) as Record<string, string>;
@@ -109,225 +276,179 @@ export async function POST(request: NextRequest) {
const joinConfig = getJoinPaymentConfig();
const payConfig = getPaymentConfig();
const user_id = String(body?.user_id || "").trim();
const return_url = String(body?.return_url || "").trim();
const total_fee = Number(body?.total_fee) || joinConfig.amount;
const hostHeader =
request.headers.get("host") || request.headers.get("x-forwarded-host");
const apiUrl = resolvePayApiUrl(request);
const userId = String(body?.user_id || "").trim();
const totalFee = Number(body?.total_fee) || joinConfig.amount;
const type = String(body?.type || joinConfig.type);
const channel = String(body?.channel || "alipay");
const device = String(body?.device || "pc"); // pc | h5 | wechat微信内传 wechat
const channel = String(body?.channel || "wxpay");
const device = String(body?.device || "pc");
const forcedProvider = device === "wechat" ? "xorpay" : undefined;
if (!user_id) {
return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 400 });
if (!userId) {
return NextResponse.json(
{ ok: false, error: "缺少 user_id" },
{ status: 400 }
);
}
const origin =
request.headers.get("x-forwarded-host")
? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
: request.headers.get("origin") || SITE_URL;
// Z-Pay return_url 不支持带参数,使用 /join/paid 专用路径
const finalReturnUrl = return_url || `${origin}/zh/join/paid`;
const returnUrl =
String(body?.return_url || "").trim() || `${origin}/zh/join/paid`;
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"";
const wantHtml =
String(body?.html) === "1" ||
(request.headers.get("accept") || "").includes("text/html");
const { params, payUrl } = await createPayOrder(
user_id,
finalReturnUrl,
total_fee,
type,
channel
console.log(
"[pay] digital apiUrl=%s host=%s provider=%s device=%s",
apiUrl,
hostHeader,
forcedProvider || "default",
device
);
if (!params || Object.keys(params).length === 0) {
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
const created = await createPayOrder(
apiUrl,
userId,
returnUrl,
totalFee,
type,
channel,
forcedProvider,
clientIp
);
if (!wantHtml) {
const response = NextResponse.json({
ok: true,
params: created.params,
payUrl: created.payUrl,
provider: created.provider,
order_id: created.orderId,
});
if (created.orderId) setPayOrderCookie(response, created.orderId);
return response;
}
const accept = request.headers.get("accept") || "";
const wantHtml = String(body?.html) === "1" || accept.includes("text/html");
if (wantHtml) {
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"";
// 使用 payjsapi /payh5/redirectmapi.php API接口支付按 device 返回 payurl/payurl2
const redirectPayload = {
user_id,
site_id: SITE_ID,
total_fee,
type,
channel,
return_url: finalReturnUrl,
device,
...(clientIp && { client_ip: clientIp }),
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const redirectRes = await fetch(`${payConfig.apiUrl}/digital/payh5/redirect`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(redirectPayload),
redirect: "manual",
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
const location = redirectRes.headers.get("location");
if (location) {
const res = NextResponse.redirect(location);
// payh5/redirect 创建的是新订单,必须用其返回的 order_id非 createPayOrder 的 params
const orderId =
redirectRes.headers.get("x-pay-order-id")?.trim() ||
String(params?.out_trade_no || "").trim();
if (orderId) {
// 附带时间戳,前端只对 15 分钟内设置的 cookie 轮询,避免陈旧 cookie 误触发
const payload = `${orderId}|${Date.now()}`;
res.cookies.set("join_pay_order", payload, { path: "/", maxAge: 900 });
}
return res;
}
}
const resData = await redirectRes.json().catch(() => ({}));
// 微信内payurl2 会提示「请外微信外打开」,改用收银台表单由 Z-Pay 识别 UA
if (redirectRes.status === 200 && resData?.use_form) {
const html = buildPayHtml(payConfig.zpaySubmitUrl, params as Record<string, unknown>);
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
const orderId =
resData?.order_id?.trim() || String(params?.out_trade_no || "").trim();
if (orderId) {
res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, {
path: "/",
maxAge: 900,
});
}
return res;
}
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
const returnUrl = String(resData.return_url || finalReturnUrl).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const imgSrc = String(resData.img).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const orderId = String(resData.order_id || "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const ch = String(resData.channel || "alipay").toLowerCase();
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<style>body{font-family:system-ui;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;}h2{color:#333;margin-bottom:8px;}p{color:#64748b;font-size:14px;}img{width:220px;height:220px;margin:20px 0;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}a{color:#0ea5e9;margin-top:16px;}.tip{font-size:12px;color:#94a3b8;margin-top:8px;}</style></head>
<body>
<h2>${esc(title)}</h2>
<p>${esc(hint)}</p>
<img src="${imgSrc}" alt="支付二维码" />
<p class="tip" id="tip">支付成功后将自动跳转...</p>
<script>
(function(){
var orderId="${orderId}";
var returnUrl="${returnUrl}";
if(!orderId){return;}
var tip=document.getElementById("tip");
var t=0;
var maxT=300;
function check(){
t++;
if(t>maxT){tip.textContent="轮询超时";return;}
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
.then(function(r){return r.json();})
.then(function(d){
if(d.ok&&d.paid){window.location.href=returnUrl;return;}
setTimeout(check,2000);
})
.catch(function(){setTimeout(check,2000);});
}
setTimeout(check,2000);
})();
</script>
</body></html>`;
return new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
const errData = resData;
if (redirectRes.status === 400 || redirectRes.status === 500) {
return NextResponse.json(
{ ok: false, error: errData?.detail || errData?.msg || "支付跳转失败" },
{ status: redirectRes.status }
);
}
// 降级payjsapi 未提供 redirect 或非 zpay使用原有表单提交逻辑
const formParams = new URLSearchParams();
const missing: string[] = [];
for (const key of ZPAY_REQUIRED) {
const v = params[key];
if (v == null || String(v).trim() === "") {
missing.push(key);
} else {
formParams.append(key, String(v));
}
}
for (const [k, v] of Object.entries(params)) {
if (!ZPAY_REQUIRED.includes(k) && v != null && String(v).trim() !== "") {
formParams.append(k, String(v));
}
}
if (missing.length > 0) {
return NextResponse.json(
{
ok: false,
error: `payjsapi 返回参数缺少: ${missing.join(", ")}。请确认 payjsapi 已设置 PAYMENT_PROVIDER=zpay`,
},
{ status: 500 }
);
}
const zpayRes = await fetch(payConfig.zpaySubmitUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formParams.toString(),
redirect: "manual",
if (created.provider === "xorpay") {
const html = buildPayHtml(created.payUrl, created.params, {
directToCashier: true,
orderId: created.orderId,
});
const location = zpayRes.headers.get("location");
if ((zpayRes.status === 301 || zpayRes.status === 302) && location) {
const res = NextResponse.redirect(location);
const outTradeNo = String(params?.out_trade_no || "").trim();
if (outTradeNo) {
const payload = `${outTradeNo}|${Date.now()}`;
res.cookies.set("join_pay_order", payload, { path: "/", maxAge: 900 });
}
return res;
}
const text = await zpayRes.text();
try {
const errJson = JSON.parse(text);
if (errJson?.code === "error") {
return NextResponse.json(
{ ok: false, error: errJson.msg || "ZPAY 返回错误" },
{ status: 500 }
);
}
} catch {
/* ignore */
}
const html = buildPayHtml(payUrl, params as Record<string, unknown>);
return new NextResponse(html, {
const response = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (created.orderId) setPayOrderCookie(response, created.orderId);
return response;
}
return NextResponse.json({
ok: true,
pay_url: payUrl,
params,
provider: "zpay",
submit_method: "POST",
const redirectPayload: Record<string, unknown> = {
user_id: userId,
site_id: SITE_ID,
total_fee: totalFee,
type,
channel,
return_url: returnUrl,
device,
...(clientIp ? { client_ip: clientIp } : {}),
};
try {
const redirectRes = await requestRedirect(
apiUrl,
"/digital/payh5/redirect",
redirectPayload
);
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
const location = redirectRes.headers.get("location");
if (location) {
const response = NextResponse.redirect(location);
const orderId =
redirectRes.headers.get("x-pay-order-id")?.trim() ||
created.orderId;
if (orderId) setPayOrderCookie(response, orderId);
return response;
}
}
const resData = await redirectRes.json().catch(() => ({}));
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
const safeReturnUrl = String(resData.return_url || returnUrl).replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
c
] || c)
);
const imgSrc = String(resData.img).replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
c
] || c)
);
const rawOrderId = String(resData.order_id || created.orderId).trim();
const html = buildQrHtml(imgSrc, safeReturnUrl, rawOrderId, {
channel: resData.channel || "wxpay",
successDesc: "支付成功,课程已解锁",
});
const response = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (rawOrderId) setPayOrderCookie(response, rawOrderId);
return response;
}
if (redirectRes.status === 200 && resData?.use_form) {
const html = buildPayHtml(payConfig.zpaySubmitUrl, created.params, {
directToCashier: true,
orderId: String(resData.order_id || created.orderId).trim(),
});
const response = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
const orderId = String(resData.order_id || created.orderId).trim();
if (orderId) setPayOrderCookie(response, orderId);
return response;
}
if (redirectRes.status >= 400) {
console.warn(
"[pay] redirect fallback status=%s body=%j",
redirectRes.status,
resData
);
}
} catch (error) {
console.warn(
"[pay] redirect fallback error=%s",
error instanceof Error ? error.message : String(error)
);
}
const html = buildPayHtml(created.payUrl, created.params, {
orderId: created.orderId,
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
const response = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (created.orderId) setPayOrderCookie(response, created.orderId);
return response;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
const msg = err.message || "";
const hint =
msg.includes("fetch") || msg.includes("ECONNREFUSED")

View File

@@ -1,25 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { getPaymentConfig } from "@/config/services.config";
import {
getApiUrlFromRequest,
getPaymentConfig,
} from "@/config/services.config";
import { queryXorPayOrderStatus } from "@/app/lib/payment/xorpayStatus";
import { queryZPayOrderStatus } from "@/app/lib/payment/zpayStatus";
function resolvePayApiUrl(request: NextRequest): string {
const config = getPaymentConfig();
const hostHeader =
request.headers.get("host") || request.headers.get("x-forwarded-host");
return (getApiUrlFromRequest(hostHeader) || config.apiUrl).replace(/\/$/, "");
}
export async function GET(request: NextRequest) {
const orderId = request.nextUrl.searchParams.get("order_id");
if (!orderId) {
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
}
const config = getPaymentConfig();
if (!config.apiUrl) {
return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 });
return NextResponse.json(
{ ok: false, error: "missing order_id" },
{ status: 400 }
);
}
const hostHeader =
request.headers.get("host") || request.headers.get("x-forwarded-host");
const apiUrl = resolvePayApiUrl(request);
const url = `${apiUrl}/digital/order_status?order_id=${encodeURIComponent(orderId)}`;
console.log("[pay/status] request order_id=%s host=%s url=%s", orderId, hostHeader, url);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25000);
const res = await fetch(
`${config.apiUrl}/digital/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
{ cache: "no-store", signal: controller.signal }
).finally(() => clearTimeout(timeout));
const timeout = setTimeout(() => controller.abort(), 3000);
const res = await fetch(url, {
cache: "no-store",
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const data = await res.json().catch(() => ({}));
return NextResponse.json({ ok: true, paid: !!data?.paid });
} catch {
return NextResponse.json({ ok: true, paid: false });
const paid = !!data?.paid;
console.log(
"[pay/status] result order_id=%s paid=%s status=%s",
orderId,
paid,
res.status
);
if (paid) {
return NextResponse.json({ ok: true, paid: true });
}
} catch (error) {
console.log(
"[pay/status] error order_id=%s error=%s",
orderId,
error instanceof Error ? error.message : String(error)
);
}
const xorpay = await queryXorPayOrderStatus(orderId, 5000);
console.log(
"[pay/status] xorpay fallback order_id=%s paid=%s status=%s error=%s url=%s",
orderId,
!!xorpay?.paid,
xorpay?.status || "",
xorpay?.error || "",
xorpay?.url || ""
);
if (xorpay?.paid) {
return NextResponse.json({ ok: true, paid: true });
}
const zpay = await queryZPayOrderStatus(orderId, 5000);
console.log(
"[pay/status] zpay fallback order_id=%s paid=%s status=%s error=%s url=%s",
orderId,
!!zpay?.paid,
zpay?.status || "",
zpay?.error || "",
zpay?.url || ""
);
if (zpay?.paid) {
return NextResponse.json({ ok: true, paid: true });
}
return NextResponse.json({ ok: true, paid: false });
}

View File

@@ -4,18 +4,23 @@ import { getPocketBaseConfig, SITE_ID, VIP_MASTER_SITE_ID } from "@/config/servi
const COOKIE_NAME = "pb_session";
async function getAdminToken(): Promise<string | null> {
const email = process.env.POCKETBASE_EMAIL;
const password = process.env.POCKETBASE_PASSWORD;
const email = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL;
const password = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_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;
const paths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"];
for (const path of paths) {
const res = await fetch(`${pb.url}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (res.ok) {
const data = await res.json();
if (data?.token) return data.token;
}
}
return null;
}
/** 检查单条 site_vip 是否有效 */
@@ -26,18 +31,45 @@ function isValidVip(record: { expires_at?: string | null } | null): boolean {
}
export async function GET(request: NextRequest) {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) {
return NextResponse.json({ ok: true, vip: false });
const token = await getAdminToken();
if (!token) return NextResponse.json({ ok: true, vip: false });
let userId: string | null = null;
// 优先用邮箱查Header 传 email更可靠
const email = request.nextUrl.searchParams.get("email")?.trim();
if (email) {
const pb = getPocketBaseConfig();
const filter = `email="${email.replace(/"/g, '\\"')}"`;
const collections = [pb.usersCollection, "_pb_users_auth_"];
for (const coll of collections) {
const userRes = await fetch(
`${pb.url}/api/collections/${coll}/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (userRes.ok) {
const userData = await userRes.json();
userId = userData?.items?.[0]?.id ?? null;
if (userId) break;
}
}
}
// 无邮箱或未查到:回退到 cookie 中的 userId
if (!userId) {
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"));
userId = payload?.userId ?? null;
} catch {
return NextResponse.json({ ok: true, vip: false });
}
}
if (!userId) 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();
// 1) 本专题站 VIPsite_id = SITE_ID2) vip 站 master VIPsite_id = vip可解锁所有 digital 专题站
const filter = `user_id = "${userId}" && (site_id = "${SITE_ID}" || site_id = "${VIP_MASTER_SITE_ID}")`;

View File

@@ -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,48 @@ 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);
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:success"));
}
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 +82,26 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden
/>
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden />
<div className="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
<button
onClick={onClose}
className="absolute right-4 top-4 text-slate-400 hover:text-slate-600"
aria-label="关闭"
aria-label="Close"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-bold text-slate-800">
{mode === "login" ? "登录" : "注册"}
</h2>
<h2 className="text-xl font-bold text-slate-800">{mode === "login" ? "Login" : "Register"}</h2>
<p className="mt-1 text-sm text-slate-500">
{mode === "login" ? "使用邮箱密码登录" : "创建新账号"}
{mode === "login" ? "Use email and password" : "Create a new account"}
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700"></label>
<label className="mb-1 block text-sm font-medium text-slate-700">Email</label>
<input
type="email"
value={email}
@@ -106,12 +112,12 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700"></label>
<label className="mb-1 block text-sm font-medium text-slate-700">Password</label>
<input
type="password"
value={password}
onChange={(e) => 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 +125,31 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
</div>
{mode === "register" && (
<div>
<label className="mb-1 block text-sm font-medium text-slate-700"></label>
<label className="mb-1 block text-sm font-medium text-slate-700">Confirm Password</label>
<input
type="password"
value={passwordConfirm}
onChange={(e) => 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"
/>
</div>
)}
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
)}
{error && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-sky-500 py-3 font-semibold text-white transition-colors hover:bg-sky-600 disabled:opacity-70"
>
{loading ? "处理中…" : mode === "login" ? "登录" : "注册"}
{loading ? "Processing..." : mode === "login" ? "Login" : "Register"}
</button>
</form>
<p className="mt-4 text-center text-sm text-slate-500">
{mode === "login" ? (
<>
{" "}
No account?{" "}
<button
type="button"
onClick={() => {
@@ -154,12 +158,12 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
}}
className="font-medium text-sky-500 hover:text-sky-600"
>
Register
</button>
</>
) : (
<>
{" "}
Already have an account?{" "}
<button
type="button"
onClick={() => {
@@ -168,7 +172,7 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
}}
className="font-medium text-sky-500 hover:text-sky-600"
>
Login
</button>
</>
)}

View File

@@ -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<string, string> = {
cloudPhone: "☁️",
unmannedLive: "📺",
};
const linkHrefs: Record<string, { href: string; newTab?: boolean }> = {
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<string, { href: string; newTab?: boolean }> = {
destinations: { href: meetupUrl, newTab: true },
vipEntry: { href: vipUrl, newTab: true },
appDownload: { href: "/app" },
};
const showUpdating = () => {
setToast(true);

View File

@@ -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 (
<footer className="border-t border-slate-100 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8 lg:py-16">

View File

@@ -1,17 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
import { getStoredUserEmail } from "@/app/lib/pocketbase";
import { useCrossSiteUrls } from "@/app/lib/useCrossSiteUrls";
import AuthModal from "./AuthModal";
import ThemeToggle from "./ThemeToggle";
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" },
];
import UserAvatar from "./UserAvatar";
export default function Header() {
const { t } = useTranslation("common");
@@ -23,32 +18,50 @@ export default function Header() {
const [scrolled, setScrolled] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [vip, setVip] = useState(false);
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;
(async () => {
try {
const res = await fetch("/api/auth/me", { credentials: "include" });
const data = await res.json();
if (!mounted) return;
if (data?.user && data?.token) {
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(data.token, { id: data.user.id, email: data.user.email });
setUserEmail(data.user.email);
return;
}
} catch {
/* ignore */
const fetchAuthAndVip = useCallback(async () => {
try {
const meRes = await fetch("/api/auth/me", { credentials: "include" });
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 });
setUserEmail(meData.user.email);
// 用邮箱查 VIP比 cookie userId 更可靠
const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
const vipData = await vipRes.json();
setVip(vipData?.vip === true);
} else {
setUserEmail(getStoredUserEmail());
setVip(false);
}
if (mounted) setUserEmail(getStoredUserEmail());
})();
return () => { mounted = false; };
} catch {
/* ignore */
}
}, []);
const handleLogout = async () => {
await pbLogout();
setUserEmail(null);
};
useEffect(() => {
fetchAuthAndVip();
}, [fetchAuthAndVip]);
useEffect(() => {
const onVipUpdated = () => fetchAuthAndVip();
const onAuthSuccess = () => fetchAuthAndVip();
window.addEventListener("vip:updated", onVipUpdated);
window.addEventListener("auth:success", onAuthSuccess);
return () => {
window.removeEventListener("vip:updated", onVipUpdated);
window.removeEventListener("auth:success", onAuthSuccess);
};
}, [fetchAuthAndVip]);
return (
<header
@@ -81,17 +94,7 @@ export default function Header() {
<div className="flex shrink-0 items-center gap-1 sm:gap-3">
{userEmail ? (
<div className="flex items-center gap-2">
<span className="hidden max-w-[120px] truncate text-sm text-slate-600 dark:text-slate-400 sm:inline">
{userEmail}
</span>
<button
onClick={handleLogout}
className="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800"
>
{t("logout")}
</button>
</div>
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); router.replace("/"); }} />
) : (
<button
onClick={() => setAuthOpen(true)}
@@ -160,17 +163,9 @@ export default function Header() {
{locale === "zh" ? "EN" : "中文"}
</button>
{userEmail ? (
<div className="mt-2 flex items-center justify-between rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
<span className="truncate text-sm text-slate-600">{userEmail}</span>
<button
onClick={() => {
handleLogout();
setMobileOpen(false);
}}
className="text-sm font-medium text-sky-500"
>
{t("logout")}
</button>
<div className="mt-2 flex items-center gap-3 rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); setMobileOpen(false); router.replace("/"); }} />
<span className="truncate text-sm text-slate-600 dark:text-slate-400">{userEmail}</span>
</div>
) : (
<button
@@ -197,7 +192,7 @@ export default function Header() {
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={(email) => setUserEmail(email)}
onSuccess={() => fetchAuthAndVip()}
/>
</header>
);

View File

@@ -0,0 +1,13 @@
"use client";
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
/**
* 全局支付轮询:支付返回任意页面时检测 paid调用 confirm 后跳转首页
*/
export function PayStatusPollProvider() {
usePayStatusPoll((_orderId) => {
window.location.href = "/";
});
return null;
}

View File

@@ -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 (
<section id="roadmap" className="bg-slate-50 py-20 sm:py-28 dark:bg-slate-900">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
@@ -90,20 +84,8 @@ export default function Roadmap() {
</span>
</Link>
<button
type="button"
onClick={async () => {
if (loading) return;
if (!user) {
setAuthOpen(true);
return;
}
if (!vip) {
router.replace("/join");
return;
}
router.replace("/course");
}}
<Link
href="/course"
className="group flex w-full flex-col overflow-hidden rounded-2xl border-2 border-amber-200 bg-gradient-to-br from-amber-50 to-orange-50/50 p-8 text-left shadow-md transition-all hover:border-amber-300 hover:shadow-lg dark:border-amber-800 dark:from-amber-900/20 dark:to-orange-900/10 dark:hover:border-amber-700"
>
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-r from-amber-500 to-orange-500 text-3xl shadow-lg shadow-amber-200/50 dark:shadow-amber-900/30">
@@ -121,19 +103,9 @@ export default function Roadmap() {
<span className="mt-6 inline-flex items-center gap-2 self-start rounded-full bg-amber-500 px-6 py-3 font-semibold text-white transition group-hover:bg-amber-600 dark:bg-amber-600 dark:group-hover:bg-amber-500">
{t("course.cta")}
</span>
</button>
</Link>
</div>
</div>
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={async () => {
setAuthOpen(false);
const { vip: isVip } = await refetch();
router.replace(isVip ? "/course" : "/join");
}}
/>
</div>
</section>
);

View File

@@ -0,0 +1,75 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "@/i18n/navigation";
import { pbLogout } from "@/app/lib/pocketbase";
function getAvatarLetter(email: string): string {
const local = email.split("@")[0];
if (!local) return "?";
return local.slice(0, 1).toUpperCase();
}
interface UserAvatarProps {
email: string;
vip?: boolean;
onLogout?: () => void;
onRefreshVip?: () => void;
}
/** 圆形头像 + VIP 标识 + 点击展开退出按钮,供 digital / meetup / vip 三站复用 */
export default function UserAvatar({ email, vip, onLogout }: UserAvatarProps) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleLogout = async () => {
await pbLogout();
setOpen(false);
onLogout?.();
};
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-sky-500 text-sm font-semibold text-white shadow-sm ring-2 ring-white dark:ring-slate-900 hover:bg-sky-600"
aria-label={t("logout")}
>
{getAvatarLetter(email)}
{vip && (
<span
className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-amber-400 text-[10px] text-amber-900"
title="VIP"
>
👑
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
{vip && (
<div className="border-b border-slate-100 px-4 py-2 text-xs font-medium text-amber-600 dark:border-slate-700 dark:text-amber-400">
👑 VIP
</div>
)}
<button
onClick={handleLogout}
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700"
>
{t("logout")}
</button>
</div>
)}
</div>
);
}

View File

@@ -3,20 +3,91 @@
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,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import { useState } from "react";
export function CTASection() {
const { user, vip } = useAuthAndVip();
const { user, vip, refetch } = useAuthAndVip();
const paid = !!user && vip;
const [authOpen, setAuthOpen] = useState(false);
const [payRedirecting, setPayRedirecting] = useState(false);
const startPay = (userId: string) => {
const env = getPayEnv();
const channel = "wxpay";
const device = getDeviceFromEnv(env);
const returnUrl = `${window.location.origin}/`;
redirectToPay({
user_id: userId,
return_url: returnUrl,
channel,
device,
type: "video",
useJoinConfig: true,
});
};
const handleEnroll = async () => {
if (payRedirecting) return;
setPayRedirecting(true);
try {
// 先验证登录态
let meRes = await fetch("/api/auth/me", { credentials: "include" });
let meData = await meRes.json().catch(() => ({}));
if (meData?.user?.id) {
// 已登录:先校验 VIP已是 VIP 则无需支付
const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
const vipData = await vipRes.json().catch(() => ({}));
if (vipData?.vip) {
await refetch();
return;
}
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);
meRes = await fetch("/api/auth/me", { credentials: "include" });
meData = await meRes.json().catch(() => ({}));
if (meData?.user?.id) {
const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
const vipData = await vipRes.json().catch(() => ({}));
if (vipData?.vip) {
await refetch();
return;
}
startPay(meData.user.id);
return;
}
}
setAuthOpen(true);
} finally {
setPayRedirecting(false);
}
};
return (
<section className="border-t border-[var(--border)] py-16 sm:py-20 md:py-24">
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6">
<h2 className="mb-4 text-xl font-bold sm:text-2xl md:text-3xl">
{CTA_CONFIG.title}
</h2>
<p className="mb-6 text-[var(--muted-foreground)] sm:mb-8">
{CTA_CONFIG.subtitle}
</p>
<h2 className="mb-4 text-xl font-bold sm:text-2xl md:text-3xl">{CTA_CONFIG.title}</h2>
<p className="mb-6 text-[var(--muted-foreground)] sm:mb-8">{CTA_CONFIG.subtitle}</p>
<div className="mb-8 flex flex-wrap justify-center gap-4 text-sm text-[var(--muted-foreground)] sm:mb-10 sm:gap-6">
{CTA_CONFIG.badges.map((b, i) => (
<span
@@ -36,14 +107,24 @@ export function CTASection() {
</Link>
) : (
<Link
href="/join"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition hover:opacity-95 sm:px-10"
<button
type="button"
onClick={handleEnroll}
disabled={payRedirecting}
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition hover:opacity-95 disabled:opacity-70 sm:px-10"
>
</Link>
{payRedirecting ? "跳转支付中..." : "立即报名 →"}
</button>
)}
</div>
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={() => {
setAuthOpen(false);
void refetch();
}}
/>
</section>
);
}

View File

@@ -4,14 +4,23 @@ 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,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
export function CourseHero() {
const { user, vip } = useAuthAndVip();
const { user, vip, refetch } = useAuthAndVip();
const [last, setLast] = useState<ReturnType<typeof getLastWatched>>(null);
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
const [authOpen, setAuthOpen] = useState(false);
const [payRedirecting, setPayRedirecting] = useState(false);
useEffect(() => {
setLast(getLastWatched());
@@ -26,6 +35,71 @@ export function CourseHero() {
const paid = !!user && vip;
const startPay = (userId: string) => {
const env = getPayEnv();
const channel = "wxpay";
const device = getDeviceFromEnv(env);
const returnUrl = `${window.location.origin}/`;
redirectToPay({
user_id: userId,
return_url: returnUrl,
channel,
device,
type: "video",
useJoinConfig: true,
});
};
const handleEnroll = async () => {
if (payRedirecting) return;
setPayRedirecting(true);
try {
// 先验证登录态
let meRes = await fetch("/api/auth/me", { credentials: "include" });
let meData = await meRes.json().catch(() => ({}));
if (meData?.user?.id) {
// 已登录:先校验 VIP已是 VIP 则无需支付
const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
const vipData = await vipRes.json().catch(() => ({}));
if (vipData?.vip) {
await refetch();
return;
}
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);
meRes = await fetch("/api/auth/me", { credentials: "include" });
meData = await meRes.json().catch(() => ({}));
if (meData?.user?.id) {
const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
const vipData = await vipRes.json().catch(() => ({}));
if (vipData?.vip) {
await refetch();
return;
}
startPay(meData.user.id);
return;
}
}
setAuthOpen(true);
} finally {
setPayRedirecting(false);
}
};
return (
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--gradient-accent),transparent)] animate-gradient" />
@@ -39,12 +113,8 @@ export function CourseHero() {
<div className="mb-10 flex flex-wrap justify-center gap-6 sm:mb-12 sm:gap-8">
{HERO_CONFIG.stats.map((s) => (
<div key={s.label} className="text-center transition-transform duration-300 hover:scale-105">
<div className="text-2xl font-bold text-[var(--accent)] sm:text-3xl md:text-4xl">
{s.value}
</div>
<div className="mt-1 text-xs text-[var(--muted-foreground)] sm:text-sm">
{s.label}
</div>
<div className="text-2xl font-bold text-[var(--accent)] sm:text-3xl md:text-4xl">{s.value}</div>
<div className="mt-1 text-xs text-[var(--muted-foreground)] sm:text-sm">{s.label}</div>
</div>
))}
</div>
@@ -64,12 +134,14 @@ export function CourseHero() {
</Link>
) : (
<Link
href="/join"
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
<button
type="button"
onClick={handleEnroll}
disabled={payRedirecting}
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 disabled:opacity-70 sm:px-10"
>
</Link>
{payRedirecting ? "跳转支付中..." : "立即报名 →"}
</button>
)}
</div>
{paid && progress.completed > 0 && (
@@ -79,14 +151,19 @@ export function CourseHero() {
<span>{progress.completed}/{TOTAL_LESSONS} </span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full rounded-full bg-[var(--accent)] transition-all"
style={{ width: `${progress.percent}%` }}
/>
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress.percent}%` }} />
</div>
</div>
)}
</div>
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={() => {
setAuthOpen(false);
void refetch();
}}
/>
</section>
);
}

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { Link } from "@/i18n/navigation";
import { Section } from "./Section";
import { CURRICULUM_CONFIG } from "@/config/course";
import { canWatchLesson } from "@/app/lib/course-payment";
import { canWatchLesson, isFreeTrial } from "@/app/lib/course-payment";
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/lib/learning";
@@ -99,7 +99,6 @@ export function CurriculumSection() {
{isOpen && (
<ul className="border-t border-[var(--border)]">
{c.lessons.map((l) => {
const free = l.moduleIndex === 0 && l.lessonIndex < 2;
const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex, vip);
const key = `${l.moduleIndex}-${l.lessonIndex}`;
const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex);
@@ -126,7 +125,7 @@ export function CurriculumSection() {
<span className={unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"}>
{l.name}
</span>
{free && (
{isFreeTrial(l.moduleIndex, l.lessonIndex) && (
<span className="shrink-0 rounded bg-[var(--accent)]/20 px-1.5 py-0.5 text-xs text-[var(--accent)]">
</span>

View File

@@ -101,7 +101,7 @@ web电子书也是自己开发的,开源的也很丰富,但比起研究配置,
> SRS直播服务器
<!-- 图片已移除 -->
![](C:\Users\admin\AppData\Roaming\marktext\images\2025-12-14-05-33-29-image.png)
除了内容直播,还可以结合互动api (弹幕/礼物),进行游戏
@@ -421,11 +421,11 @@ ps: 再次申明,`资本/金融`不在本文赘述
> 直接买号/买频道
<!-- 图片已移除 -->
![](C:\Users\admin\AppData\Roaming\marktext\images\2025-12-16-04-54-47-image.png)
> 刷粉刷量
<!-- 图片已移除 -->
![](C:\Users\admin\AppData\Roaming\marktext\images\2025-12-16-04-56-43-image.png)
---
@@ -4444,3 +4444,458 @@ openclaw甚至可以运行在5$的esp32单片机上,而AI能力,只需要订阅
即便是云端的大模型如何强大,都推荐在自己的homelab`私有化部署`,不仅仅能保证业务隐私,也可以让算力成为超级杠杆,不必担心token变成巨额账单
<img src="https://minioweb.hackrobot.cn/hackrobot/download_20260212_094938_23729.jpg" title="" alt="download_20260212_094938_23729.jpg" width="406">
数字游民:低成本AI工作室:云手机,无人直播Tiktok/Youtube/SaaS,赚美金 (28)
# 数字员工
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/qweqw_20260306_044520_10932.jpg" alt="qweqw_20260306_044520_10932.jpg" width="358">
在openclaw(龙虾)出现之前,只能用代码/N8N进行`RPA自动化`,中间的节点,用AI润色
但是现在,只需要在chatbot (聊天机器人),对它用`大白话`说出要求,就可以实现全部功能了
比如
- 分析摄像头流,当有人/车进入画面,进行通知+响铃,并进行门店客流统计,打印报告
- 浏览器打开`nomadro.com` (出海导航),分析该网站收录了哪些信息,有什么价值
- 安卓手机打开X/小红书,自动浏览,并分析画面的内容进行数据分析,对特定主题自动点赞/评论
一行代码都不需要写,直接得到结果
> 24小时打工
用GPT进行对话是实时的,它不会在多少小时后给你汇报,但是龙虾可以
它可以在睡觉的时候,继续工作
并且每个龙虾,可以设定专业的`角色分工`,比如:客服/程序员/设计师/会计等
<img src="https://minioweb.hackrobot.cn/hackrobot/sdfds_20260306_051002_15775.jpg" title="" alt="sdfds_20260306_051002_15775.jpg" width="387">
它只是`把网络/电力/算力,变成了AI货币:token`
可以做员工经营业务,也可以直接对外出售变现
与雇佣真实的人类比较,它很便宜,且长久
---
## 硬件
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/wqqw_20260306_050044_13949.jpg" alt="wqqw_20260306_050044_13949.jpg" width="439">
开源项目openclaw和大模型对mac芯片/系统做了适配和优化
所以很多人选择`mac mini`作为家庭服务器
它静音/省电,能私有化部署,不必担心天价的token账单和隐私风险
> 但本文以`显卡`GPU为例
前些年配置了RTX4060TI 16gb和RTX3060TI 12gb,都是NVIDIA,CUDA对AI大模型支持友好
游戏笔记本也含有8gb的显存
为了单独配置小龙虾,在海鲜市场,又淘了 RTX3060 12gb,以及` P40 24gb(矿卡)`
> P40 24gb 矿卡
<img src="https://minioweb.hackrobot.cn/hackrobot/20260306051213_20260306_051229_16255.png" title="" alt="20260306051213_20260306_051229_16255.png" width="344">
目前市场价0.8k左右,24gb的显存,不用担心服务宕机,效果与RTX 3060 其实差不多,目前在ubuntu server运行`Qwen3.5-35B-A3B`模型,35B占比22gb显存,多模态(图片识别)占用2gb,刚刚好=24gb
实际速度,`30token/s`
另外:9B与35B对比,前者是玩具,不足以胜任数字员工的标准
P40配置如下
<img src="file:///C:/Users/admin/AppData/Roaming/marktext/images/2026-03-06-05-13-22-image.png" title="" alt="" width="377">
---
## 部署
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/wqqwd_20260306_051706_17156.jpg" alt="wqqwd_20260306_051706_17156.jpg" width="443">
大模型选择qwen3.5
只需要在小黄脸(huggingface)下载`GGUF模型文件`,:
![20260306051825_20260306_051855_17512.png](https://minioweb.hackrobot.cn/hackrobot/20260306051825_20260306_051855_17512.png)
`llama.cpp`运行如下命令
```
llama-server -m Qwen3.5-35B-A3B-Q4_K_M.gguf --mmproj mmproj-BF16.gguf -ngl 40 --threads 12 --host 0.0.0.0 --port 8007 -c 32768 --verbose --no-mmap --image-min-tokens 1024 -b 256 -ub 128 -np 1 --main-gpu 0 --reasoning_format none
```
网盘下载地址,可以后台私信: `qwen`,获取网盘地址
然后就有了是部署在本地的`qwen3.5-35B-A3B`模型的webui界面
<img src="https://minioweb.hackrobot.cn/hackrobot/20260306044856_20260306_045718_13276.png" title="" alt="20260306044856_20260306_045718_13276.png" width="455">
小龙虾openclaw部署,也只需要一行命令
```
curl -fsSL https://openclaw.ai/install.sh | bash
```
> openclaw的web管理面板
<img src="https://minioweb.hackrobot.cn/hackrobot/20260306042805_20260306_045918_13672.png" title="" alt="20260306042805_20260306_045918_13672.png" width="458">
> 对话,配置telegram
要求获取服务器的配置和信息
<img src="https://minioweb.hackrobot.cn/hackrobot/20260305195953_20260306_045820_13479.png" title="" alt="20260305195953_20260306_045820_13479.png" width="325">
openclaw交流,可以后台私信:`加群`
---
## 最后
<img src="https://minioweb.hackrobot.cn/hackrobot/download_20260306_052519_18769.jpg" title="" alt="download_20260306_052519_18769.jpg" width="344">
龙虾openclaw用AI控制你的手机/电脑/电器设备
具体到浏览器网页/app,以及更底层的控制协议http/adb等等
在不断地迭代升级中,确实能成为一名优秀的数字员工
它现在并不是玩具,是未来
数字游民:低成本AI工作室:云手机,无人直播Tiktok/Youtube/SaaS,赚美金 (29)
## vibe coding
在openclaw(龙虾)爆火之前,垂直于编程领域的商业平台`cursor``Claude` 以及`codex`,就已经能非常出色的完成各种任务,比如实现各种网页/app/小程序,全程大白话,不用手写一句代码
最初用于补充代码段,完成组件,还需要手动编程,但现在已经能`全业务流程,跨项目文件,多线程开发`
<img src="https://minioweb.hackrobot.cn/hackrobot/20260314205131_20260314_205212_20958.png" title="" alt="20260314205131_20260314_205212_20958.png" width="414">
举个例子,一段话,它就能在`2小时`内生成了3个前端项目,1个共用后端:
"你是高级架构师,是全栈工程师,也是优秀的设计师,请用nextjs构建`数字游民`为主题的资源导航/数据saas/私域vip的3个网站,并生成基于fastapi的后端项目,使用pocketbase数据库和minio S3存储"
生成过程,加上样式/排版/功能调教,也不过2小时
如果外包高级程序员,没有1个月,几个w的预算,是无法实现的,但实际的消费只有20$*3=60美元
其实也可以使用vscode编辑器加本地大模型(显卡)API,但效果比云端商业模型会逊色一点,适合后期调教,比如qweb35 -35B-A3B目前运行openclaw基本任务很不错
网站界面/功能如下:
> 数字游民saas
![20260314203304_20260314_203518_17644.png](https://minioweb.hackrobot.cn/hackrobot/20260314203304_20260314_203518_17644.png)
<img src="https://minioweb.hackrobot.cn/hackrobot/20260314203157_20260314_204949_20488.png" title="" alt="20260314203157_20260314_204949_20488.png" width="206">
> 资源导航
<img src="https://minioweb.hackrobot.cn/hackrobot/20260314203337_20260314_205019_20589.png" title="" alt="20260314203337_20260314_205019_20589.png" width="446">
> 私域会员
<img src="https://minioweb.hackrobot.cn/hackrobot/20260314203359_20260314_205042_20664.png" title="" alt="20260314203359_20260314_205042_20664.png" width="461">
目前网站已经具备了各种功能:
- markdown文档/电子书/视频播放/网盘下载
- 登录注册/权限控制/数据可视化/表单
当然,由于定位比较全面,不是SPA那种landing page
所以很多具体的模块都要不断实现和优化
---
## 超级助理
本期只讨论商业平台`cursor ``Claude `以及`codex`的效果
openclaw(龙虾)可以查看上一期文章
除了之前说的vibe coding,氛围编程,普通人要的是偏向于效率或者生产力的任务
`思维导图`有很多年了,一个30MB的文件,迭代了无数次的思考和总结
<img src="https://minioweb.hackrobot.cn/hackrobot/20260314205421_20260314_205913_22330.png" title="" alt="20260314205421_20260314_205913_22330.png" width="395">
让codex对导图文件进行分析总结
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/mindmap_wechat_long_20260314_211208_24864.png" alt="mindmap_wechat_long_20260314_211208_24864.png" width="428">
再比如`独角数卡`电商站在服务器异常,去冰箱倒一杯橙汁的功夫,codex就完成了数据备份,服务修复,以及启动脚本优化
在此之前,我尝试用chatgpt plus对话,复制粘贴,花了半小时都没有搞定docker redis损坏的问题
模型都是同样的GPT5.4,但是工作流和效果完全不一样
<img src="https://minioweb.hackrobot.cn/hackrobot/20260314211031_20260314_211116_24694.png" title="" alt="20260314211031_20260314_211116_24694.png" width="362">
---
## 普通人如何利用AI赚钱?
AI在每个领域可以做的事情实现是太多了
本文只是简单阐述了vibe coding和文件分析/服务器运维的简单应用
比如还有视频剪辑/生成,金融量化交易,电商运营等等
这里有个关键概念是:`平台优先`,不是to c(个人用户),也不是to B (企业)
如果一个标榜数字游民的人只是靠教别人做数字游民卖课赚钱,那就是击鼓传花的割韭菜
就像在深圳中心书城看到一本躺在角落"如何编写出版千万的畅销书"的冷笑话~
海外平台优先是`Youtube/Tiktok/X`,以及独立开发saas( 接入谷歌oauth登录和stripe支付)
去了解ypp(Youtube)/ Rewards(tiktok) /Premium(X)的广告分成计划,靠流量,美金变现
再去了解`订阅拉新(offer)/佣金带货(affiliate)/电商卖货(shop)`更多渠道
<img src="https://minioweb.hackrobot.cn/hackrobot/wdw_20260314_215829_1174.jpg" title="" alt="wdw_20260314_215829_1174.jpg" width="436">
为了更通俗的阐述基本获利方式,接下来以`国内平台`为例子
> 广告:流量主
追求长期,可以分享在AI领域的新闻热点,以及实践心得
- 新模型 / 新 agent / 新浏览器自动化 / 新视频工作流
- 对创作者更省时间的功能
- 能把原本 2 小时流程压到 10 分钟的工具
目前所有媒体平台,都对 hash tag标签 #AI 算法推荐
短期爆发
- 写一篇专题,深度好文,用专业的理论和实践心得,使用各种    emoji/gif/表格/视频    表达
- 推广:付费投流
基于真实的分享,仅仅平台的广告费就不少,一旦粉丝达到1w以上,商业投放就会纷至沓来
ps:数据可怜,就打码了,但苍蝇再小也是肉
<img src="https://minioweb.hackrobot.cn/hackrobot/20260314215009_20260314_221543_4554.png" title="" alt="20260314215009_20260314_221543_4554.png" width="257">
> 拉新:网盘奖励
网盘的`拉新/转存`,都是有现金激励的
由于国内网络的限制,以及安装的繁琐,可以为普通用户提供2个东西
- 大模型下载
- 一键懒人安装包 (windows)
没有让用户付费,但每天的被动收入源源不断 (国内是 百度网盘/夸克网盘)
当然,如同支付一样,官方把渠道授权给了第三方公司,进行管理和分成
你可以去出海导航 `nomadro.com`,查看第三方平台推荐
<img src="https://minioweb.hackrobot.cn/hackrobot/20260314215058_20260314_221557_4600.png" title="" alt="20260314215058_20260314_221557_4600.png" width="277">
> 佣金:云服务器
<img src="https://minioweb.hackrobot.cn/hackrobot/download_20260314_220323_2137.jpg" title="" alt="download_20260314_220323_2137.jpg" width="321">
openclaw的安装,`腾讯云`就提供了云服务器,开箱即用
在文章里告诉用户,贴上URL/小程序
企鹅推出了龙虾专属服务器,下单即可使用,大厂出品
> 远程技术支持
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/download_20260314_215505_511.jpg" alt="download_20260314_215505_511.jpg" width="274">
就算写了文章:安装教程,提供一键懒人包和大模型下载,但是依然有很多用户不会
所以海鲜市场就有非常多人提供:`远程安装`
只需要利用rustdesk到等远程电脑控制软件,就可以快速解决软件安装问题(你愿意上门也可以)
ps: 公众号流量主/云服务器佣金/网盘拉新/远程安装
> 总结
如果你对AI保持热情,长期分享实践,仅仅国内的微信公众号,就可以有被动收入的基本框架
当然,更推荐去拓展海外市场,流量和汇率,是国内的10x以上
在研究挣钱这件事上,先选好利基(比如AI agent),再布置好免费获利框架(ads广告/offer订阅/affiliate佣金),后续再研究更多收费盈利模式(直播/带货/电商/咨询)等等
先从自己需求出发,再利他
数字游民:低成本AI工作室:云手机,无人直播Tiktok/Youtube/SaaS,赚美金 (30)
# AI算力工作室
关于赚钱,我一直推崇做"无人直播"
AI的`数字人/一键换脸/姿态迁移`,可以从内容上解决24小时UGC的问题
小龙虾openclaw也可以从`流程自动化``数据分析`上进行管理
抛开业务本身不谈,这期只简单聊下`本地部署`的自由主义:
1. Token无限 (电力转成算力)
2. 隐私保护 (商业数据不被云厂商收集)
3. 灰色和黑产 (内容开放,无神茶)
<img src="https://minioweb.hackrobot.cn/hackrobot/dqwdq_20260323_041648_5943.jpg" title="" alt="dqwdq_20260323_041648_5943.jpg" width="444">
比起 `arm开发板(云手机)`,以及`AI眼镜/全景相机`,`显卡`是当下工作室最好的投资
如果从成本上考虑,可以选择矿卡P40 (24gb)/千元,选择可以含有多个pcie插槽的主板,显存足够
可以部署多个数字助理,分配到不同的业务
---
## 大模型:Token自由
个人不太建议用windows去部署懒人包
虽然它会让`小白`,快速自部署AI脚本,运行服务,但是由于操作系统本身的特性,会导致性能有所牺牲
推荐用linux系统,比如ubuntu,来测试AI程序
由于server服务器,天生就是ssh到终端执行cli操作,遇到任何问题,交给小龙虾或者商业平台(codex)之类,都能自动化快速解决,不必有运维知识
> 基座:llama.cpp
qwen3.5开源后,建议下载大模型文件,用`llama.cpp`部署
这样你就拥有了类似chatgpt一样的网页,以及`标准的api`
![20260323034426_20260323_034443_32422.png](https://minioweb.hackrobot.cn/hackrobot/20260323034426_20260323_034443_32422.png)
> Agent数字助理
为什么要使用API?
除了gpt网页对话,现在的`数字助理`,是借助大模型的思考,来实现`agent`操作
白话就是有`权限和能力`在你的操作系统:windows/mac os/linux/android,执行各种任务
比如openclaw就可以调用本地的qwen3.5 api,以及telegram实现对话指令 (国内有飞书和微信clawBot插件)
<img src="https://minioweb.hackrobot.cn/hackrobot/20260323040033_20260323_040050_2815.png" title="" alt="20260323040033_20260323_040050_2815.png" width="253">
> vide coding
其实就是`AI+独立开发`, 只不过再也不用用手敲打一行代码了
可以在vscode和cursor集成 本地大模型
分分钟实现网站功能(登录/支付/工具),然后立马上线 (海外域名/vps,无需备案,秒访问)
<img src="https://minioweb.hackrobot.cn/hackrobot/20260323052413_20260323_052430_19198.png" title="" alt="20260323052413_20260323_052430_19198.png" width="356">
> 无神茶
以上的'本地网页gpt'/"Agent数字助理"/'独立开发',都是老生常谈
但是本地最大的意义就是保护隐私和"无神茶"
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/20260323041000_20260323_041016_4660.png" alt="20260323041000_20260323_041016_4660.png" width="394">
阿里公布的模型文件自带"神茶",以至于AI会拒绝回答很多问题
或者在圣母婊的基础上给出不痛不痒的废话
但是作为土生土长的神国人,想要生存,赚钱,是不得已需要知道很多灰色/跨境的方法论的
于是有人基于开源模型,进行训练处理,就得到了解锁的`Uncensored-HauhauCS-Aggressive`版本
这也是本篇文章的灵魂:`Qwen3.5-35B-A3B-Uncensored-HauhauCS-Aggressive`
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/dqwd_20260323_042520_7612.jpg" alt="dqwd_20260323_042520_7612.jpg" width="400">
关于AI的生产力课程:agnet助理,有在筹备
有兴趣可私信: `加群`
但出于业务繁忙和后台消息众多,不一定能及时更新上线
---
## 低成本预算
虽然一直在讨论`AI的生产力``业务的关联`,但回归到硬件,与工作室本身
如何低成本部署多套设备呢?答案是:`闲鱼`
<img src="https://minioweb.hackrobot.cn/hackrobot/wqweq_20260323_043903_10299.jpg" title="" alt="wqweq_20260323_043903_10299.jpg" width="409">
- 1.搜索`电脑/主机 `
选择`搬家`原因的`同城自提`
主机比较大,不方便携带,由于要搬家,也比较急出手,这样就可以购买千元以内的主机
包含完整的CPU芯片/内存/硬盘
- 2.矿卡/计算卡
比如大显存的P40 (24GB), 也是千元以下,ps:不推荐魔改卡,容易坏
当然,如果要省心,也可以选择mac mini,但是一旦会话题吵起来,售价就会水涨船高
就像是数字游民`旅居地`,最近的惠州,一旦被公开宣扬,势必会造成一波游客新增
> 成本总计
按2k/套, 5个数字助理的成本`不过万元`, 且是家电家宽
在经营业务的投资成本上,不值一提
无论是内网穿透,还是打通chatbot,都可以随时随地远程使用
---
## 最后
> 没有任何贬义,用着国内的的AI产品,和裸奔有什么区别呢?
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/20260323045406_20260323_045503_13434.png" alt="20260323045406_20260323_045503_13434.png" width="209">
除了telegram,其实个人真正的团队和业务,都是打通在开源的`element`,基于matrix通讯协议
chatbot一直都是最佳的应用入口,网页不是
有兴趣的可以回顾往期的 `远程团队`
> 碎碎念
最近在勘察旅居地,所以即使vibe coding很多专题网站,但内容也没来得及更新
周末针对`小红书受众`在loft民宿做了2场沙龙 (入口在`nomadro.com`)
也是后期的一个方向:`旅居/共创`,而不是纯粹的`技术/业务`单向输出分享
ps: 相对于咖啡馆/茶舍/联合办公空间/户外天幕 `200/h的场地报价`,loft民宿性价比很高,配上投影仪,更易于交流分享
数字游民系列至此写了1-30篇,其中序号缺失的是被平台删除了,最近大动作不断,有些如履薄冰
可后台私信:`电子书`,下载完整pdf文档

View File

@@ -3,6 +3,7 @@ import "@fontsource-variable/geist";
import "@fontsource-variable/geist-mono";
import "./globals.css";
import { ThemeProvider } from "./context/ThemeContext";
import { PayStatusPollProvider } from "./components/PayStatusPollProvider";
import { getThemeConfig } from "@/config";
export const viewport: Viewport = {
@@ -47,11 +48,12 @@ export default function RootLayout({
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body
className="antialiased font-sans"
>
<body className="antialiased font-sans">
<ThemeScript />
<ThemeProvider>{children}</ThemeProvider>
<ThemeProvider>
<PayStatusPollProvider />
{children}
</ThemeProvider>
</body>
</html>
);

34
app/lib/auth-cookie.ts Normal file
View File

@@ -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<string, unknown> = {
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;
}

View File

@@ -3,9 +3,8 @@
* 与 join 报名支付分离,课程解锁用 VIP 状态
*/
/** 前 2 章试看,无需支付 */
export function isFreeTrial(moduleIndex: number, lessonIndex: number): boolean {
if (moduleIndex === 0 && lessonIndex < 2) return true;
/** 前 2 章试看(暂时关闭,全部需 VIP 解锁) */
export function isFreeTrial(_moduleIndex: number, _lessonIndex: number): boolean {
return false;
}

View File

@@ -9,9 +9,12 @@ type LocaleLinkProps = React.ComponentProps<typeof NextLink>;
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 <NextLink href={localeHref} {...props} />;
}

View File

@@ -36,15 +36,11 @@ export function getPayEnv(): PayEnv {
}
/**
* 根据环境推荐/强制支付通道
* - 微信浏览器:只能用 wxpay支付宝在微信内无法唤起
* - 支付宝浏览器:优先 alipay
* - PC/H5用户可选
* 根据环境推荐支付通道
* 所有支付默认微信支付,支付宝暂不提供
*/
export function getRecommendedChannel(env: PayEnv): PayChannel {
if (env === "wechat") return "wxpay";
if (env === "pc" || env === "h5") return "alipay"; // 默认支付宝
return "alipay";
export function getRecommendedChannel(_env: PayEnv): PayChannel {
return "wxpay";
}
/** 微信内是否只能选微信支付 */

View File

@@ -1,13 +1,10 @@
/**
* 微信 H5 支付完成后可能不跳转,通过 cookie 中的订单号轮询检测支付状态
* cookie 格式order_id|timestamp仅对 15 分钟内设置的 cookie 轮询,避免陈旧 cookie 误触发
*/
import { useEffect, useRef, useState } from "react";
const COOKIE_NAME = "join_pay_order";
const POLL_INTERVAL = 2000;
const MAX_POLLS = 150; // 约 5 分钟
const MAX_AGE_MS = 15 * 60 * 1000; // 15 分钟
const STORAGE_NAME = "join_pay_order";
const POLL_INTERVAL_MS = 1200;
const MAX_POLLS = 60;
const MAX_AGE_MS = 15 * 60 * 1000;
function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
@@ -20,56 +17,162 @@ function clearCookie(name: string): void {
document.cookie = `${name}=; path=/; max-age=0`;
}
/** 解析 cookieorder_id|timestamp返回 [orderId, isValid]。无时间戳视为过期并清除 */
function parsePayOrderCookie(raw: string | null): [string | null, boolean] {
function getStorage(name: string): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(name);
}
function removeStorage(name: string): void {
if (typeof window === "undefined") return;
window.localStorage.removeItem(name);
}
function clearPendingOrder(): void {
clearCookie(COOKIE_NAME);
removeStorage(STORAGE_NAME);
}
function persistPendingOrder(raw: string): void {
if (typeof document !== "undefined") {
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(raw)}; path=/; max-age=900`;
}
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(STORAGE_NAME, raw);
} catch {}
}
}
function parsePendingOrder(raw: string | null): [string | null, boolean] {
if (!raw?.trim()) return [null, false];
const parts = raw.split("|");
const orderId = parts[0]?.trim() || raw.trim();
const ts = parts[1] ? parseInt(parts[1], 10) : NaN;
if (!orderId) return [null, false];
if (Number.isNaN(ts)) return [orderId, false]; // 旧格式无时间戳,视为过期
if (Date.now() - ts > MAX_AGE_MS) return [orderId, false]; // 超过 15 分钟
if (Number.isNaN(ts)) return [orderId, false];
if (Date.now() - ts > MAX_AGE_MS) return [orderId, false];
return [orderId, true];
}
export function usePayStatusPoll(onPaid: () => void): boolean {
export function usePayStatusPoll(onPaid: (orderId: string) => void): boolean {
const [polling, setPolling] = useState(false);
const onPaidRef = useRef(onPaid);
onPaidRef.current = onPaid;
useEffect(() => {
const raw = getCookie(COOKIE_NAME);
const [orderId, isValid] = parsePayOrderCookie(raw);
if (!orderId || !isValid) {
if (raw) clearCookie(COOKIE_NAME);
const urlOrderId =
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("order_id") || ""
: "";
const cookieRaw = getCookie(COOKIE_NAME);
const [cookieOrderId, cookieValid] = parsePendingOrder(cookieRaw);
const storageRaw = getStorage(STORAGE_NAME);
const [storageOrderId, storageValid] = parsePendingOrder(storageRaw);
let orderId = cookieValid ? cookieOrderId : storageValid ? storageOrderId : null;
let activeRaw = cookieValid ? cookieRaw : storageValid ? storageRaw : null;
if (!orderId && urlOrderId.trim()) {
orderId = urlOrderId.trim();
activeRaw = `${orderId}|${Date.now()}`;
persistPendingOrder(activeRaw);
}
if (!orderId) {
if (cookieRaw || storageRaw) {
clearPendingOrder();
}
return;
}
if (activeRaw) {
persistPendingOrder(activeRaw);
}
setPolling(true);
let count = 0;
const timer = setInterval(async () => {
count++;
if (count > MAX_POLLS) {
clearInterval(timer);
clearCookie(COOKIE_NAME);
let attempts = 0;
let stopped = false;
let timer: number | undefined;
let inFlight = false;
const stop = () => {
stopped = true;
if (timer) window.clearTimeout(timer);
};
const scheduleNext = () => {
if (stopped) return;
timer = window.setTimeout(tick, POLL_INTERVAL_MS);
};
const tick = async () => {
if (stopped) return;
if (inFlight) {
scheduleNext();
return;
}
attempts += 1;
if (attempts > MAX_POLLS) {
stop();
clearPendingOrder();
setPolling(false);
return;
}
inFlight = true;
try {
const res = await fetch(`/api/pay/status?order_id=${encodeURIComponent(orderId)}`);
const data = await res.json();
const res = await fetch(
`/api/pay/status?order_id=${encodeURIComponent(orderId)}`,
{ cache: "no-store" }
);
const data = await res.json().catch(() => ({}));
if (data?.ok && data?.paid) {
clearInterval(timer);
clearCookie(COOKIE_NAME);
stop();
clearPendingOrder();
setPolling(false);
onPaidRef.current();
// 对齐 nomadvip调用 confirm 写入 PocketBase site_vip不依赖 payjsapi 异步 notify
try {
const confirmRes = await fetch("/api/pay/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
cache: "no-store",
});
if (confirmRes.ok && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("vip:updated"));
}
} catch {
/* 忽略,不影响跳转 */
}
onPaidRef.current(orderId);
return;
}
} catch {
// 继续轮询
// keep polling
} finally {
inFlight = false;
}
}, POLL_INTERVAL);
return () => clearInterval(timer);
scheduleNext();
};
const handleResume = () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
return;
}
void tick();
};
document.addEventListener("visibilitychange", handleResume);
window.addEventListener("pageshow", handleResume);
window.addEventListener("focus", handleResume);
void tick();
return () => {
document.removeEventListener("visibilitychange", handleResume);
window.removeEventListener("pageshow", handleResume);
window.removeEventListener("focus", handleResume);
stop();
};
}, []);
return polling;

View File

@@ -0,0 +1,49 @@
import { createHash } from "crypto";
const XORPAY_AID = process.env.XORPAY_AID || "8220";
const XORPAY_SECRET =
process.env.XORPAY_SECRET || "afcacd99570945f88de62624aaa3578e";
const XORPAY_QUERY_URL =
process.env.XORPAY_QUERY_URL || "https://xorpay.com/api/query2";
export async function queryXorPayOrderStatus(
orderId: string,
timeoutMs = 5000
): Promise<{ paid: boolean; status?: string; error?: string; url: string } | null> {
const normalizedOrderId = orderId.trim();
if (!normalizedOrderId || !XORPAY_AID || !XORPAY_SECRET) {
return null;
}
const sign = createHash("md5")
.update(`${normalizedOrderId}${XORPAY_SECRET}`, "utf8")
.digest("hex")
.toLowerCase();
const url = new URL(
`${XORPAY_QUERY_URL.replace(/\/$/, "")}/${encodeURIComponent(XORPAY_AID)}`
);
url.searchParams.set("order_id", normalizedOrderId);
url.searchParams.set("sign", sign);
try {
const res = await fetch(url.toString(), {
cache: "no-store",
signal: AbortSignal.timeout(timeoutMs),
});
const data = await res.json().catch(() => ({}));
const status = String(data?.status || "").trim().toLowerCase();
return {
paid: res.ok && (status === "payed" || status === "success"),
status,
url: url.toString(),
error: res.ok ? undefined : `status=${res.status}`,
};
} catch (error) {
return {
paid: false,
url: url.toString(),
error: error instanceof Error ? error.message : String(error),
};
}
}

View File

@@ -0,0 +1,45 @@
const ZPAY_PID = process.env.ZPAY_PID || "2025121809351743";
const ZPAY_KEY = process.env.ZPAY_KEY || "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89";
const ZPAY_QUERY_URL =
process.env.ZPAY_QUERY_URL || "https://zpayz.cn/api.php";
export async function queryZPayOrderStatus(
orderId: string,
timeoutMs = 5000
): Promise<{ paid: boolean; status?: string; error?: string; url: string } | null> {
const normalizedOrderId = orderId.trim();
if (!normalizedOrderId || !ZPAY_PID || !ZPAY_KEY) {
return null;
}
const url = new URL(ZPAY_QUERY_URL);
url.searchParams.set("act", "order");
url.searchParams.set("pid", ZPAY_PID);
url.searchParams.set("key", ZPAY_KEY);
url.searchParams.set("out_trade_no", normalizedOrderId);
try {
const res = await fetch(url.toString(), {
cache: "no-store",
signal: AbortSignal.timeout(timeoutMs),
});
const data = await res.json().catch(() => ({}));
const code = String(data?.code || "").trim();
const status = String(data?.status || "").trim();
return {
paid: res.ok && code === "1" && status === "1",
status,
url: url.toString(),
error:
res.ok && code === "1"
? undefined
: String(data?.msg || `status=${res.status}`),
};
} catch (error) {
return {
paid: false,
url: url.toString(),
error: error instanceof Error ? error.message : String(error),
};
}
}

View File

@@ -67,11 +67,17 @@ export function pbSaveAuth(token: string, record: AuthUser): void {
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
}
/** 登出 - 清除本地存储和根域 Cookie */
/** 登出 - 清除本地存储、支付订单 cookie 和根域 Cookie */
export async function pbLogout(): Promise<void> {
if (typeof window === "undefined") return;
localStorage.removeItem(PB_STORAGE_KEYS.token);
localStorage.removeItem(PB_STORAGE_KEYS.user);
localStorage.removeItem("join_pay_order");
try {
document.cookie = "join_pay_order=; path=/; max-age=0";
} catch {
/* ignore */
}
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch {
@@ -79,19 +85,26 @@ export async function pbLogout(): Promise<void> {
}
}
/** 获取当前存储的用户邮箱 */
export function getStoredUserEmail(): string | null {
/** 获取当前存储的用户id + email */
export function getStoredUser(): AuthUser | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(PB_STORAGE_KEYS.user);
if (!raw) return null;
const user = JSON.parse(raw);
return user?.email ?? null;
if (!user?.id || !user?.email) return null;
return { id: user.id, email: user.email };
} catch {
return null;
}
}
/** 获取当前存储的用户邮箱 */
export function getStoredUserEmail(): string | null {
const user = getStoredUser();
return user?.email ?? null;
}
/** 获取存储的 token用于 API 请求) */
export function getStoredToken(): string | null {
if (typeof window === "undefined") return null;

View File

@@ -3,6 +3,7 @@ export {
pbRegister,
pbSaveAuth,
pbLogout,
getStoredUser,
getStoredUserEmail,
getStoredToken,
} from "./auth";

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getStoredUser, getStoredToken } from "@/app/lib/pocketbase";
export interface AuthAndVipState {
user: { id: string; email: string } | null;
@@ -14,24 +15,46 @@ export function useAuthAndVip(): AuthAndVipState {
const [vip, setVip] = useState(false);
const [loading, setLoading] = useState(true);
const refetch = useCallback(async (): Promise<{ user: typeof user; vip: boolean }> => {
const refetch = useCallback(async (): Promise<{ user: { id: string; email: string } | null; vip: boolean }> => {
setLoading(true);
try {
const [meRes, vipRes] = await Promise.all([
fetch("/api/auth/me", { credentials: "include" }),
fetch("/api/vip/check", { credentials: "include" }),
]);
const meRes = await fetch("/api/auth/me", { credentials: "include" });
const meData = await meRes.json();
const vipData = await vipRes.json();
const newUser = meData?.user ?? null;
const newVip = vipData?.vip === true;
let newUser = meData?.user ?? null;
if (!newUser) {
const storedUser = getStoredUser();
const storedToken = getStoredToken();
if (storedUser && storedToken) {
newUser = storedUser;
try {
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: storedToken, record: storedUser }),
credentials: "include",
});
} catch {
/* ignore */
}
}
}
let newVip = false;
if (newUser?.email) {
const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(newUser.email)}`, { credentials: "include" });
const vipData = await vipRes.json();
newVip = vipData?.vip === true;
}
setUser(newUser);
setVip(newVip);
return { user: newUser, vip: newVip };
} catch {
setUser(null);
const storedUser = getStoredUser();
setUser(storedUser);
setVip(false);
return { user: null, vip: false };
return { user: storedUser, vip: false };
} finally {
setLoading(false);
}

View File

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

View File

@@ -185,7 +185,7 @@ export const FAQ_CONFIG = {
title: "常见问题",
items: [
{ q: "课程可以永久回看吗?", a: "可以,报名后永久有效,随时回看。" },
{ q: "适合零基础吗?", a: "适合。课程从入门到进阶,前 2 节可免费试看。" },
{ q: "适合零基础吗?", a: "适合。课程从入门到进阶;视频试看暂时关闭,报名后可解锁全部章节。" },
{ q: "有社群答疑吗?", a: "有,报名后加入专属社群,导师和同学一起交流。" },
{ q: "支持退款吗?", a: "7 天内不满意可申请全额退款。" },
],

View File

@@ -16,15 +16,20 @@ 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}`;
/** 支付 API 默认地址 */
/** 支付 API 默认地址。生产环境固定为 api.${ROOT_DOMAIN},避免 .env 中 127.0.0.1 导致支付失败 */
export const DEFAULT_PAYMENT_API_URL = isDev
? "http://127.0.0.1:8700"
? "http://127.0.0.1:8007"
: `https://api.${ROOT_DOMAIN}`;
/** 生产环境若 PAYMENT_API_URL 指向 localhost则忽略并使用 api 域名 */
export function resolvePaymentApiUrl(): string {
const url = process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL;
if (!isDev && /^(https?:\/\/)?(127\.0\.0\.1|localhost)(:\d+)?(\/|$)/i.test(url)) {
return `https://api.${ROOT_DOMAIN}`;
}
return url;
}
/** PocketBase 默认地址 */
export const DEFAULT_POCKETBASE_URL = `https://pocketbase.${ROOT_DOMAIN}`;
@@ -35,3 +40,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}/`;

View File

@@ -7,6 +7,7 @@
import { getThemeConfig } from "./site.config";
import {
DEFAULT_PAYMENT_API_URL,
resolvePaymentApiUrl,
DEFAULT_POCKETBASE_URL,
DEFAULT_MINIO_HOST,
} from "./domain.config";
@@ -64,6 +65,7 @@ export interface ServicesConfig {
}
const isDev = process.env.NODE_ENV === "development";
const PAYJSAPI_PORT = process.env.PAYJSAPI_PORT || "8007";
/** 站点标识,用于 VIP 按站点隔离。digital 专题站ai.xx/android.xx各自独立如 digital_ai、digital_android */
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "digital";
@@ -71,6 +73,13 @@ export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "digital";
/** vip 站vip.hackrobot.cn的 site_id其付费 VIP 可解锁所有 digital 专题站 */
export const VIP_MASTER_SITE_ID = "vip";
export function getApiUrlFromRequest(hostHeader: string | null): string | null {
if (!hostHeader) return null;
if (!isDev) return resolvePaymentApiUrl();
const hostname = hostHeader.split(":")[0];
return `http://${hostname}:${PAYJSAPI_PORT}`;
}
/** 获取 PocketBase 配置(可传入主题覆盖) */
export function getPocketBaseConfig(themeOverride?: { joinCollection?: string }): PocketBaseConfig {
const joinCollection =
@@ -89,9 +98,9 @@ export function getPocketBaseConfig(themeOverride?: { joinCollection?: string })
export function getPaymentConfig(themeOverride?: { amount?: number; type?: string }): PaymentConfig {
const base = {
enabled: true,
apiUrl: process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL,
apiUrl: resolvePaymentApiUrl(),
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 100,
defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 8800,
defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup",
zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
};

View File

@@ -95,7 +95,7 @@ export const THEME_CONFIGS: Record<ThemeId, ThemeConfig> = {
accent: "amber",
},
services: {
join: { amount: 100, type: "meetup" },
join: { amount: 8800, type: "meetup" },
pocketbaseJoinCollection: "solan",
shopUrl: process.env.NEXT_PUBLIC_SHOP_URL || "https://store.hackrobot.cn/", // 独立站域名
},

View File

@@ -0,0 +1,55 @@
# 登录/支付/VIP 权限检查报告
检查范围digital、cnomadcna、nomadvip、payjsapi
## 一、登录流程
| 项目 | /api/auth/me | /api/auth/sync-session | /api/auth/logout | credentials |
|------|--------------|------------------------|------------------|-------------|
| digital | ✅ 读 pb_sessionrefresh 回写 | ✅ 设置 Cookie | ✅ 清除 Cookie | ✅ include |
| cnomadcna | ✅ | ✅ | ✅ | ✅ AuthModal/JoinModal |
| nomadvip | ✅ | ✅ | ✅ | ✅ AuthForm |
**Cookie domain**
- digital已用 `getAuthCookieDomain(request)`localhost 不设 domain
- cnomadcna已改为 `getAuthCookieDomain`localhost 兼容
- nomadvip已改为 `getAuthCookieDomain`localhost 兼容
## 二、支付流程
| 项目 | 前端入口 | Next.js API | payjsapi 路径 | site_id |
|------|----------|-------------|---------------|---------|
| digital | join 页 / Roadmap | /api/pay | /digital/payh5 | digital |
| cnomadcna | HeroSection JoinModal | /api/pay | /cnomadcna/payh5 | meetup |
| nomadvip | 支付页 | 直接调 payjsapi | /nomadvip/payh5 | vip |
**payjsapi 回调:**
- digital/cnomadcna`_digital_base`xorpay_notify / zpay_notify → `handle_payment_success` → site_vip
- nomadvipxorpay_notify / zpay_notify → `handle_payment_success` → site_vip
- meetup 类型effective_site_id 固定为 "meetup"
- vip 类型effective_site_id 为 "vip"
## 三、VIP 权限
| 项目 | SITE_ID | VIP 校验逻辑 |
|------|---------|--------------|
| digital | digital | site_id=digital 或 site_id=vip |
| cnomadcna | meetup | site_id=meetup |
| nomadvip | vip | site_id=vip |
**site_vip 表:** user_id + site_id + order_id + expires_at
## 四、环境变量
| 变量 | 说明 |
|------|------|
| AUTH_COOKIE_DOMAIN | 生产环境根域,如 .hackrobot.cn |
| POCKETBASE_EMAIL / POCKETBASE_PASSWORD | VIP 查询用 admin |
| PAYMENT_API_URL | payjsapi 地址,如 https://api.hackrobot.cn |
| NEXT_PUBLIC_POCKETBASE_URL | PocketBase 地址 |
| NEXT_PUBLIC_SITE_ID | digital / meetup / vip |
## 五、本次修复
1. **cnomadcna**auth/me、sync-session、logout 改用 `getAuthCookieDomain`,支持 localhost
2. **nomadvip**:同上,新增 `app/lib/auth-cookie-domain.ts`

View File

@@ -439,6 +439,19 @@ digital 专题站 VIP 校验:用户有 VIP 若满足其一:① 本专题站
---
## 七.2 meetup / vip 站跨站 SSO 实现清单
若 meetup.hackrobot.cn、vip.hackrobot.cn 刷新后仍无登录态,请确认:
1. **必须实现** `/api/auth/me`GET读取 `pb_session` Cookie解析 token调用 PocketBase refresh 验证,返回 `{ user, token }`。digital 站已实现,可参考 `app/api/auth/me/route.ts`
2. **必须实现** `/api/auth/sync-session`POST登录成功后由前端调用将 token 写入 `Domain=.hackrobot.cn` 的 Cookie。
3. **必须实现** `/api/auth/logout`POST清除 `pb_session` Cookie。
4. **启动时**:先调 `GET /api/auth/me`,若有 `user``pbSaveAuth` 并视为已登录;若无则读 localStorage 兼容。
5. **Cookie domain**:生产环境(`*.hackrobot.cn`)必须设置 `domain: ".hackrobot.cn"`localhost 开发时 domain 应为 `undefined`(不设),否则 Cookie 无法写入。digital 已通过 `getAuthCookieDomain(request)` 自动处理。
6. **顶部头像**登录后在顶部显示圆形头像邮箱首字母点击展开退出按钮。digital 已实现 `UserAvatar` 组件(`app/components/UserAvatar.tsx`meetup/vip 可复制该组件并在 Header 中替换原有登录态展示。
---
## 八、安全与注意点
1. **Cookie 安全**`pb_session` 建议 `httpOnly`,防止 XSS 窃取。

View File

@@ -0,0 +1,133 @@
# 生产环境配置指南
各项目在部署到生产环境时,需在项目根目录创建 `.env.local`(或 `.env.production`),并配置以下变量。
---
## 一、digital数字游民指南
路径:`digital/.env.local`
```bash
# ========== 根域与 Cookie跨站 SSO 必填)==========
ROOT_DOMAIN=hackrobot.cn
AUTH_COOKIE_DOMAIN=.hackrobot.cn
# ========== PocketBase ==========
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn
POCKETBASE_EMAIL=你的PocketBase管理员邮箱
POCKETBASE_PASSWORD=你的PocketBase管理员密码
# ========== 支付payjsapi==========
PAYMENT_API_URL=https://api.hackrobot.cn
# ========== 站点 ==========
NEXT_PUBLIC_SITE_ID=digital
NEXT_PUBLIC_SITE_URL=https://digital.hackrobot.cn
```
---
## 二、cnomadcna游牧中国
路径:`cnomadcna/.env.local`
```bash
# ========== 根域与 Cookie跨站 SSO 必填)==========
ROOT_DOMAIN=hackrobot.cn
AUTH_COOKIE_DOMAIN=.hackrobot.cn
# ========== PocketBase ==========
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn
POCKETBASE_EMAIL=你的PocketBase管理员邮箱
POCKETBASE_PASSWORD=你的PocketBase管理员密码
# ========== 支付payjsapi==========
PAYMENT_API_URL=https://api.hackrobot.cn
# ========== 站点 ==========
NEXT_PUBLIC_SITE_ID=meetup
NEXT_PUBLIC_SITE_URL=https://meetup.hackrobot.cn
```
---
## 三、nomadvip异度星球
路径:`nomadvip/.env.local`
```bash
# ========== 根域与 Cookie跨站 SSO 必填)==========
AUTH_COOKIE_DOMAIN=.hackrobot.cn
# ========== PocketBase ==========
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn
# ========== 支付payjsapi==========
PAYMENT_API_URL=https://api.hackrobot.cn
NEXT_PUBLIC_API_URL=https://api.hackrobot.cn
# ========== 站点 ==========
NEXT_PUBLIC_SITE_ID=vip
```
---
## 四、payjsapi支付后端
路径:`payjsapi/.env`
```bash
# ========== 回调地址ZPAY 异步通知必填)==========
BASE_URL=https://api.hackrobot.cn
# ========== 支付渠道 ==========
PAYMENT_PROVIDER=zpay
# ========== ZPAY ==========
ZPAY_PID=你的ZPAY商户ID
ZPAY_KEY=你的ZPAY密钥
# ========== PocketBase ==========
PB_URL=https://pocketbase.hackrobot.cn
PB_ADMIN_EMAIL=你的PocketBase管理员邮箱
PB_ADMIN_PASSWORD=你的PocketBase管理员密码
# ========== Memosnomadvip 专用,可选)==========
# MEMOS_API=...
# MEMOS_TOKEN=...
```
---
## 五、变量说明
| 变量 | 作用 | 说明 |
|------|------|------|
| AUTH_COOKIE_DOMAIN | 跨站 SSO | 设为 `.hackrobot.cn`,三站共享登录态 |
| POCKETBASE_EMAIL / POCKETBASE_PASSWORD | VIP 查询 | 需 PocketBase 管理员账号,用于 `/api/vip/check` |
| PAYMENT_API_URL | 支付接口 | payjsapi 部署地址 |
| NEXT_PUBLIC_SITE_ID | VIP 隔离 | digital / meetup / vip |
---
## 六、配置步骤
1. 在对应项目根目录创建 `.env.local`Next.js 项目)或 `.env`payjsapi
2. 复制上述示例,按实际域名和账号修改
3. 重启服务使配置生效
```bash
# 示例digital 项目
cd digital
cp .env.example .env.local # 或手动创建
# 编辑 .env.local 填入生产配置
```
---
## 七、注意事项
- `.env.local` 已在 `.gitignore` 中,不会被提交
- 更换域名时,同时修改 `ROOT_DOMAIN``AUTH_COOKIE_DOMAIN`(如 `.nomadro.com`
- 确保域名解析到正确服务器nginx 反向代理指向 `https://api.hackrobot.cn` → payjsapi 端口(如 8700

View File

@@ -1,8 +1,22 @@
import type { NextConfig } from "next";
/** 由 scripts/build-safe.cjs 设置;在 VPS 上也可手动 export 后执行 pnpm build */
const lowMemBuild = process.env.NEXT_BUILD_LOW_MEM === "1";
const nextConfig: NextConfig = {
experimental: {
serverActions: { bodySizeLimit: "25mb" },
...(lowMemBuild
? {
// 限制并行与预渲染并发降低峰值内存Next 16 默认 Turbopack 构建)
cpus: 1,
turbopackMemoryLimit: 1536 * 1024 * 1024,
staticGenerationMaxConcurrency: 1,
webpackMemoryOptimizations: true,
parallelServerCompiles: false,
parallelServerBuildTraces: false,
}
: {}),
},
async headers() {
const noCacheHeaders = [

View File

@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "next dev -p 3000 -H 0.0.0.0",
"build": "node ./scripts/build-safe.cjs",
"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",

30
scripts/build-safe.cjs Normal file
View File

@@ -0,0 +1,30 @@
/**
* 低端 VPS / 小内存环境:限制 Node 堆、开启 Next 低内存构建选项。
* 用法pnpm build默认经本脚本
* 可选环境变量:
* NEXT_BUILD_HEAP_MB — Node --max-old-space-size默认 1536
*/
const { spawnSync } = require("child_process");
const path = require("path");
const heapMb = process.env.NEXT_BUILD_HEAP_MB || "1536";
const nextBin = path.join(
path.dirname(require.resolve("next/package.json")),
"dist",
"bin",
"next"
);
const result = spawnSync(
process.execPath,
["--max-old-space-size=" + heapMb, nextBin, "build"],
{
stdio: "inherit",
env: {
...process.env,
NEXT_BUILD_LOW_MEM: "1",
},
}
);
process.exit(result.status ?? 1);

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* 测试 vip/check 逻辑:用邮箱查 users -> user_id -> site_vip
* 用法: node scripts/test-vip-check.mjs 283810867@qq.com
*/
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { readFileSync, existsSync } from "fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, "..");
function loadEnv() {
const paths = [join(root, ".env.local"), join(root, ".env")];
for (const p of paths) {
if (existsSync(p)) {
const content = readFileSync(p, "utf-8");
for (const line of content.split("\n")) {
const m = line.match(/^([^#=]+)=(.*)$/);
if (m) {
const key = m[1].trim();
const val = m[2].trim().replace(/^["']|["']$/g, "");
if (!process.env[key]) process.env[key] = val;
}
}
}
}
}
loadEnv();
const email = process.argv[2] || "283810867@qq.com";
const pbUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
const adminEmail = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL;
const adminPassword = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_PASSWORD;
async function main() {
console.log("=== VIP Check 测试 ===");
console.log("邮箱:", email);
console.log("PocketBase URL:", pbUrl);
console.log("Admin 已配置:", !!(adminEmail && adminPassword));
if (!adminEmail || !adminPassword) {
console.error("请配置 POCKETBASE_EMAIL 和 POCKETBASE_PASSWORD");
process.exit(1);
}
const authPaths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"];
let authRes;
for (const path of authPaths) {
authRes = await fetch(`${pbUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: adminEmail, password: adminPassword }),
});
if (authRes.ok) break;
}
if (!authRes.ok) {
console.error("Admin 登录失败:", authRes.status, await authRes.text());
process.exit(1);
}
const { token } = await authRes.json();
console.log("Admin token 获取成功");
const userFilter = `email="${email}"`;
const userUrl = `${pbUrl}/api/collections/users/records?filter=${encodeURIComponent(userFilter)}&perPage=1`;
console.log("查询 users:", userUrl);
const userRes = await fetch(userUrl, { headers: { Authorization: `Bearer ${token}` } });
const userData = await userRes.json();
console.log("users 响应 status:", userRes.status);
console.log("users 响应:", JSON.stringify(userData, null, 2));
const userId = userData?.items?.[0]?.id;
if (!userId) {
console.error("未找到用户,请检查邮箱或 users 集合");
process.exit(1);
}
console.log("user_id:", userId);
const siteVipFilter = `user_id = "${userId}" && (site_id = "digital" || site_id = "vip")`;
const siteVipUrl = `${pbUrl}/api/collections/site_vip/records?filter=${encodeURIComponent(siteVipFilter)}&perPage=2&sort=-expires_at`;
console.log("查询 site_vip:", siteVipUrl);
const vipRes = await fetch(siteVipUrl, { headers: { Authorization: `Bearer ${token}` } });
const vipData = await vipRes.json();
console.log("site_vip 响应 status:", vipRes.status);
console.log("site_vip 响应:", JSON.stringify(vipData, null, 2));
const items = vipData?.items ?? [];
const valid = items.filter((r) => !r.expires_at || r.expires_at > new Date().toISOString());
console.log("\n=== 结果 ===");
console.log("VIP:", valid.length > 0);
if (valid[0]) console.log("记录:", valid[0]);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});