Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8da75fad2 | ||
|
|
c1014909ac | ||
|
|
0528b1853a | ||
|
|
614f001986 | ||
|
|
28c21522e3 | ||
|
|
c1eb0bbcf3 | ||
|
|
2365bb9e17 |
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
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# 项目分析报告
|
||||
|
||||
## 一、项目架构概览
|
||||
|
||||
| 项目 | 端口 | 支付 API 前缀 | 站点 ID | 本地地址 | 线上地址 |
|
||||
|------|------|---------------|---------|----------|----------|
|
||||
| digital | 3000 | /digital/payh5 | digital | http://192.168.41.222:3000 | digital.hackrobot.cn |
|
||||
| cnomadcna | 3001 | /cnomadcna/payh5 | meetup | http://192.168.41.222:3001 | meetup.hackrobot.cn |
|
||||
| nomadvip | 3002 | /nomadvip/payh5 | vip | http://192.168.41.222:3002 | vip.hackrobot.cn |
|
||||
| payjsapi | 8007 | - | - | http://192.168.41.222:8007 | api.hackrobot.cn |
|
||||
|
||||
## 二、PocketBase 数据表
|
||||
|
||||
- **users**: 用户认证
|
||||
- **payments**: 支付记录
|
||||
- **site_vip**: 站点 VIP 权限 (user_id, site_id, order_id, expires_at)
|
||||
- **sites**: 站点配置
|
||||
- **solan**: 游牧中国申请表单 (需与 users 关联)
|
||||
|
||||
## 三、Bug 分析与修复方案
|
||||
|
||||
### Bug 1: digital 首页登录注册异常
|
||||
|
||||
**可能原因:**
|
||||
- Cookie 未设置 domain,跨子域或本地 IP 访问时可能失效
|
||||
- auth/me 使用 `/api/users/refresh`,需确认 PocketBase 版本兼容
|
||||
- 本地开发时 PAYMENT_API_URL 默认 8700,用户期望 8007
|
||||
|
||||
**修复:**
|
||||
- 统一 Cookie 设置逻辑,生产环境设置 AUTH_COOKIE_DOMAIN
|
||||
- 本地开发不设置 domain
|
||||
- 修正 payjsapi 端口配置
|
||||
|
||||
### Bug 2: digital /course 立即报名流程
|
||||
|
||||
**当前逻辑:** 已实现登录校验→支付→VIP 解锁,CTASection 的 handleEnroll 正确。
|
||||
|
||||
**需确认:** 支付返回后 useAuthAndVip 会 refetch,course 页面需轮询支付状态。
|
||||
|
||||
### Bug 3: cnomadcna 首页「加入游牧中国」流程不完整
|
||||
|
||||
**需求:**
|
||||
1. 检查 users 是否存在
|
||||
2. 校验 VIP 状态
|
||||
3. 老用户:弹窗密码校验,可修改密码,无 VIP 进入支付
|
||||
4. 新用户:直接进入支付
|
||||
5. 支付失败:users 新增记录但无 VIP
|
||||
6. 支付成功:解锁 VIP
|
||||
|
||||
**当前问题:**
|
||||
- check-user 只返回 exists,未返回 vip
|
||||
- 老用户有 VIP 时仍会弹窗要求支付
|
||||
- JoinModal 未区分「有 VIP 直接进入」逻辑
|
||||
|
||||
**修复:** 扩展 check-user 返回 vip,HeroSection/JoinModal 按 vip 分支处理。
|
||||
|
||||
### Bug 4: cnomadcna 支付成功后首页未显示登录
|
||||
|
||||
**原因:** cnomadcna 首页无 Header/Navbar 展示登录态。
|
||||
|
||||
**修复:** 添加 TopBar 或复用 layout 展示 auth 状态,join/paid 完成后调用 pbSaveAuth 同步 localStorage。
|
||||
|
||||
### Bug 5: cnomadcna /join 提交申请
|
||||
|
||||
**需求:** 必须先登录,solan 与 users 关联。
|
||||
|
||||
**当前问题:** api/join 的 getOrCreateUserId 在无 cookie 时返回 `user${Date.now()}` 假 ID,solan 未正确关联真实用户。
|
||||
|
||||
**修复:** 未登录时返回 401,强制登录后再提交;solan.user_id 使用 cookie 中的真实 userId。
|
||||
|
||||
## 四、环境配置
|
||||
|
||||
将创建各项目的 .env.example,本地默认:
|
||||
- PAYMENT_API_URL: http://192.168.41.222:8007
|
||||
- NEXT_PUBLIC_POCKETBASE_URL: https://pocketbase.hackrobot.cn(或本地 PB 地址)
|
||||
- 本地不设置 AUTH_COOKIE_DOMAIN
|
||||
@@ -6,7 +6,7 @@ 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";
|
||||
@@ -31,7 +31,6 @@ export default function LessonPage() {
|
||||
|
||||
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);
|
||||
@@ -146,7 +145,7 @@ export default function LessonPage() {
|
||||
|
||||
<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>
|
||||
|
||||
@@ -49,7 +49,6 @@ function resolvePayApiUrl(request: NextRequest): string {
|
||||
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
return (
|
||||
(hostHeader ? getApiUrlFromRequest(hostHeader) : null) ||
|
||||
process.env.PAYMENT_API_URL ||
|
||||
payConfig.apiUrl
|
||||
).replace(/\/$/, "");
|
||||
}
|
||||
@@ -97,7 +96,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const pb = getPocketBaseConfig();
|
||||
const joinConfig = getJoinPaymentConfig();
|
||||
const totalFee = Number(joinConfig?.amount ?? 100);
|
||||
const totalFee = Number(joinConfig?.amount ?? 8800);
|
||||
|
||||
const checkPayments = await fetch(
|
||||
`${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(`order_id="${orderId}"`)}&perPage=1`,
|
||||
|
||||
@@ -13,11 +13,7 @@ function resolvePayApiUrl(request: NextRequest): string {
|
||||
const config = getPaymentConfig();
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
return (
|
||||
getApiUrlFromRequest(hostHeader) ||
|
||||
process.env.PAYMENT_API_URL ||
|
||||
config.apiUrl
|
||||
).replace(/\/$/, "");
|
||||
return (getApiUrlFromRequest(hostHeader) || config.apiUrl).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function setPayOrderCookie(response: NextResponse, orderId: string) {
|
||||
@@ -68,36 +64,130 @@ function buildPayHtml(
|
||||
function buildQrHtml(
|
||||
imgSrc: string,
|
||||
returnUrl: string,
|
||||
orderId: 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;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;}</style></head>
|
||||
<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>
|
||||
<h2>请扫码支付</h2>
|
||||
<p>支付成功后将自动跳转</p>
|
||||
<img src="${imgSrc}" alt="支付二维码" />
|
||||
<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) {}
|
||||
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" })
|
||||
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){window.location.href=returnUrl;return;}
|
||||
setTimeout(check,1200);
|
||||
if(d.ok&&d.paid){doConfirm(0);return;}
|
||||
setTimeout(check,500);
|
||||
})
|
||||
.catch(function(){setTimeout(check,1200);});
|
||||
.catch(function(){setTimeout(check,500);});
|
||||
}
|
||||
check();
|
||||
setTimeout(check,500);
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
@@ -308,7 +398,10 @@ export async function POST(request: NextRequest) {
|
||||
] || c)
|
||||
);
|
||||
const rawOrderId = String(resData.order_id || created.orderId).trim();
|
||||
const html = buildQrHtml(imgSrc, safeReturnUrl, rawOrderId);
|
||||
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" },
|
||||
|
||||
@@ -10,11 +10,7 @@ function resolvePayApiUrl(request: NextRequest): string {
|
||||
const config = getPaymentConfig();
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
return (
|
||||
getApiUrlFromRequest(hostHeader) ||
|
||||
process.env.PAYMENT_API_URL ||
|
||||
config.apiUrl
|
||||
).replace(/\/$/, "");
|
||||
return (getApiUrlFromRequest(hostHeader) || config.apiUrl).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
|
||||
* 全局支付轮询:支付返回任意页面时检测 paid,调用 confirm 后跳转首页
|
||||
*/
|
||||
export function PayStatusPollProvider() {
|
||||
usePayStatusPoll(() => {
|
||||
usePayStatusPoll((_orderId) => {
|
||||
window.location.href = "/";
|
||||
});
|
||||
return null;
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,18 +54,27 @@ function parsePendingOrder(raw: string | null): [string | null, boolean] {
|
||||
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 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);
|
||||
const orderId = cookieValid ? cookieOrderId : storageValid ? storageOrderId : null;
|
||||
const activeRaw = cookieValid ? cookieRaw : storageValid ? storageRaw : null;
|
||||
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) {
|
||||
@@ -134,7 +143,7 @@ export function usePayStatusPoll(onPaid: () => void): boolean {
|
||||
} catch {
|
||||
/* 忽略,不影响跳转 */
|
||||
}
|
||||
onPaidRef.current();
|
||||
onPaidRef.current(orderId);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -185,7 +185,7 @@ export const FAQ_CONFIG = {
|
||||
title: "常见问题",
|
||||
items: [
|
||||
{ q: "课程可以永久回看吗?", a: "可以,报名后永久有效,随时回看。" },
|
||||
{ q: "适合零基础吗?", a: "适合。课程从入门到进阶,前 2 节可免费试看。" },
|
||||
{ q: "适合零基础吗?", a: "适合。课程从入门到进阶;视频试看暂时关闭,报名后可解锁全部章节。" },
|
||||
{ q: "有社群答疑吗?", a: "有,报名后加入专属社群,导师和同学一起交流。" },
|
||||
{ q: "支持退款吗?", a: "7 天内不满意可申请全额退款。" },
|
||||
],
|
||||
|
||||
@@ -16,11 +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";
|
||||
|
||||
/** 支付 API 默认地址(本地可用 PAYMENT_API_URL 覆盖,如 http://192.168.41.222:8007) */
|
||||
/** 支付 API 默认地址。生产环境固定为 api.${ROOT_DOMAIN},避免 .env 中 127.0.0.1 导致支付失败 */
|
||||
export const DEFAULT_PAYMENT_API_URL = isDev
|
||||
? "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}`;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { getThemeConfig } from "./site.config";
|
||||
import {
|
||||
DEFAULT_PAYMENT_API_URL,
|
||||
resolvePaymentApiUrl,
|
||||
DEFAULT_POCKETBASE_URL,
|
||||
DEFAULT_MINIO_HOST,
|
||||
} from "./domain.config";
|
||||
@@ -74,7 +75,7 @@ export const VIP_MASTER_SITE_ID = "vip";
|
||||
|
||||
export function getApiUrlFromRequest(hostHeader: string | null): string | null {
|
||||
if (!hostHeader) return null;
|
||||
if (!isDev) return process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL;
|
||||
if (!isDev) return resolvePaymentApiUrl();
|
||||
const hostname = hostHeader.split(":")[0];
|
||||
return `http://${hostname}:${PAYJSAPI_PORT}`;
|
||||
}
|
||||
@@ -97,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/", // 独立站域名
|
||||
},
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000 -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"build": "node ./scripts/build-safe.cjs",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user