Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8da75fad2 | ||
|
|
c1014909ac | ||
|
|
0528b1853a | ||
|
|
614f001986 | ||
|
|
28c21522e3 | ||
|
|
c1eb0bbcf3 | ||
|
|
2365bb9e17 | ||
|
|
a193fe4d45 | ||
|
|
37d0883ce9 | ||
|
|
ffa5043daf |
17
.env.example
Normal file
17
.env.example
Normal 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
11
.env.local
Normal 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
6
.gitignore
vendored
@@ -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
|
||||
|
||||
96
BUGS_AND_ISSUES_SUMMARY.md
Normal file
96
BUGS_AND_ISSUES_SUMMARY.md
Normal 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 未同步 localStorage,Navbar 不刷新 | join/paid 调用 `pbSaveAuth` 并派发 `auth:updated`,Navbar 监听并 refetch |
|
||||
|
||||
### 4. cnomadcna /join 提交申请
|
||||
|
||||
| 问题 | 原因 | 修复 |
|
||||
|------|------|------|
|
||||
| 未登录可提交 | getOrCreateUserId 无 cookie 时返回假 ID | 未登录返回 401,强制先登录 |
|
||||
| solan 未关联真实用户 | 使用 `user${Date.now()}` 作为 user_id | 使用 cookie 中的真实 userId |
|
||||
|
||||
### 5. 环境与配置
|
||||
|
||||
| 问题 | 原因 | 修复 |
|
||||
|------|------|------|
|
||||
| payjsapi 端口不一致 | 默认 8700,用户期望 8007 | run.py 支持 `PORT` 环境变量,默认 8007 |
|
||||
| 各项目默认支付 API 端口 | domain.config 使用 8700 | 统一改为 127.0.0.1:8007 |
|
||||
|
||||
---
|
||||
|
||||
## 二、潜在风险与待关注点
|
||||
|
||||
### 1. PocketBase 管理员认证
|
||||
|
||||
- **现象**:check-user、ensure-user、vip/check 依赖 `POCKETBASE_EMAIL`、`POCKETBASE_PASSWORD`
|
||||
- **风险**:未配置时返回「服务配置错误」或 vip 始终为 false
|
||||
- **建议**:确保各项目 `.env.local` 中正确配置管理员账号
|
||||
|
||||
### 2. ZPAY 回调地址
|
||||
|
||||
- **现象**:payjsapi 的 `BASE_URL` 需为 ZPAY 可访问的公网地址
|
||||
- **风险**:本地调试时 ZPAY 无法回调 localhost/内网,支付成功但 PocketBase 不更新
|
||||
- **建议**:本地测试可用 ngrok 暴露 8007,或部署到公网后测试回调
|
||||
|
||||
### 3. 跨域 Cookie
|
||||
|
||||
- **现象**:digital/meetup/vip 三站共享登录需 `AUTH_COOKIE_DOMAIN=.hackrobot.cn`
|
||||
- **风险**:生产环境未配置时,各子域登录态不共享
|
||||
- **建议**:生产部署时在 `.env.local` 中设置 `AUTH_COOKIE_DOMAIN=.hackrobot.cn`
|
||||
|
||||
### 4. cnomadcna 新用户预创建逻辑
|
||||
|
||||
- **现象**:`isPrivateDevHost` 时新用户会先 ensure-user 再支付,生产环境用 `pending_hex(email)` 作为 user_id
|
||||
- **风险**:生产环境新用户支付成功后才创建 users,逻辑正确;本地预创建可能掩盖问题
|
||||
- **建议**:保持现状,本地与生产流程略有差异
|
||||
|
||||
### 5. digital 支付 return_url
|
||||
|
||||
- **现象**:course 页「立即报名」的 return_url 为 `/course/0/0`
|
||||
- **风险**:支付返回后需轮询支付状态,usePayStatusPoll 依赖 `join_pay_order` cookie;digital 的 pay 路由可能未设置该 cookie
|
||||
- **建议**:确认 digital 支付返回后 course 页能正确轮询并刷新 VIP 状态
|
||||
|
||||
---
|
||||
|
||||
## 三、代码质量与结构
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| 重复逻辑 | 三个前端的 auth、pay、vip 逻辑相似,可考虑抽公共库 |
|
||||
| 硬编码 | 部分文案、金额、URL 散落代码中,建议集中到 config |
|
||||
| 错误处理 | 部分 API 的 catch 仅 `console.error`,未统一错误格式 |
|
||||
| 类型安全 | AuthModal 等处的 record 类型可再收紧 |
|
||||
|
||||
---
|
||||
|
||||
## 四、本次修改项(当前会话)
|
||||
|
||||
1. **支付测试价格**:所有前端默认 1 元(100 分)
|
||||
2. **默认 zpay 渠道**:各项目 `.env.local` 设置 `PAYMENT_PROVIDER=zpay`,cnomadcna 默认 channel 改为 alipay
|
||||
3. **.env.local**:为 digital、cnomadcna、nomadvip、payjsapi 创建本地调试配置
|
||||
4. **cnomadcna 支付页**:/api/pay 返回的 HTML 使用 `opacity:0` 隐藏「正在跳转」提示,直接进入支付流程
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ── */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
45
app/api/auth/login/route.ts
Normal file
45
app/api/auth/login/route.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
62
app/api/auth/register/route.ts
Normal file
62
app/api/auth/register/route.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
169
app/api/pay/confirm/route.ts
Normal file
169
app/api/pay/confirm/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, ">");
|
||||
}
|
||||
|
||||
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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/redirect:mapi.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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const imgSrc = String(resData.img).replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const orderId = String(resData.order_id || "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||||
c
|
||||
] || c)
|
||||
);
|
||||
const imgSrc = String(resData.img).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||||
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")
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) 本专题站 VIP(site_id = SITE_ID)2) vip 站 master VIP(site_id = vip)可解锁所有 digital 专题站
|
||||
const filter = `user_id = "${userId}" && (site_id = "${SITE_ID}" || site_id = "${VIP_MASTER_SITE_ID}")`;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
13
app/components/PayStatusPollProvider.tsx
Normal file
13
app/components/PayStatusPollProvider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
75
app/components/UserAvatar.tsx
Normal file
75
app/components/UserAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -101,7 +101,7 @@ web电子书也是自己开发的,开源的也很丰富,但比起研究配置,
|
||||
|
||||
> SRS直播服务器
|
||||
|
||||
<!-- 图片已移除 -->
|
||||

|
||||
|
||||
除了内容直播,还可以结合互动api (弹幕/礼物),进行游戏
|
||||
|
||||
@@ -421,11 +421,11 @@ ps: 再次申明,`资本/金融`不在本文赘述
|
||||
|
||||
> 直接买号/买频道
|
||||
|
||||
<!-- 图片已移除 -->
|
||||

|
||||
|
||||
> 刷粉刷量
|
||||
|
||||
<!-- 图片已移除 -->
|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -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模型文件`,:
|
||||
|
||||

|
||||
|
||||
用`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
|
||||
|
||||

|
||||
|
||||
<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`
|
||||
|
||||

|
||||
|
||||
> 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文档
|
||||
|
||||
|
||||
|
||||
@@ -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
34
app/lib/auth-cookie.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/** 微信内是否只能选微信支付 */
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
/** 解析 cookie:order_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;
|
||||
|
||||
49
app/lib/payment/xorpayStatus.ts
Normal file
49
app/lib/payment/xorpayStatus.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
45
app/lib/payment/zpayStatus.ts
Normal file
45
app/lib/payment/zpayStatus.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
pbRegister,
|
||||
pbSaveAuth,
|
||||
pbLogout,
|
||||
getStoredUser,
|
||||
getStoredUserEmail,
|
||||
getStoredToken,
|
||||
} from "./auth";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
5
app/lib/useCrossSiteUrls.ts
Normal file
5
app/lib/useCrossSiteUrls.ts
Normal 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 };
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export const FAQ_CONFIG = {
|
||||
title: "常见问题",
|
||||
items: [
|
||||
{ q: "课程可以永久回看吗?", a: "可以,报名后永久有效,随时回看。" },
|
||||
{ q: "适合零基础吗?", a: "适合。课程从入门到进阶,前 2 节可免费试看。" },
|
||||
{ q: "适合零基础吗?", a: "适合。课程从入门到进阶;视频试看暂时关闭,报名后可解锁全部章节。" },
|
||||
{ q: "有社群答疑吗?", a: "有,报名后加入专属社群,导师和同学一起交流。" },
|
||||
{ q: "支持退款吗?", a: "7 天内不满意可申请全额退款。" },
|
||||
],
|
||||
|
||||
@@ -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}/`;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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/", // 独立站域名
|
||||
},
|
||||
|
||||
55
docs/AUTH_PAYMENT_VIP_CHECK_REPORT.md
Normal file
55
docs/AUTH_PAYMENT_VIP_CHECK_REPORT.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 登录/支付/VIP 权限检查报告
|
||||
|
||||
检查范围:digital、cnomadcna、nomadvip、payjsapi
|
||||
|
||||
## 一、登录流程
|
||||
|
||||
| 项目 | /api/auth/me | /api/auth/sync-session | /api/auth/logout | credentials |
|
||||
|------|--------------|------------------------|------------------|-------------|
|
||||
| digital | ✅ 读 pb_session,refresh 回写 | ✅ 设置 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
|
||||
- nomadvip:xorpay_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`
|
||||
@@ -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 窃取。
|
||||
|
||||
133
docs/PRODUCTION_ENV_CONFIG.md
Normal file
133
docs/PRODUCTION_ENV_CONFIG.md
Normal 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管理员密码
|
||||
|
||||
# ========== Memos(nomadvip 专用,可选)==========
|
||||
# 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)
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
30
scripts/build-safe.cjs
Normal 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);
|
||||
99
scripts/test-vip-check.mjs
Normal file
99
scripts/test-vip-check.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user