diff --git a/.env.example b/.env.example index 8e8bf24..68334a4 100644 --- a/.env.example +++ b/.env.example @@ -28,5 +28,9 @@ MINIO_UPLOAD_PREFIX=joins # 站点(本地调试可改为 http://192.168.41.222:3001) NEXT_PUBLIC_SITE_URL=http://localhost:3001 +# MiroTalk P2P 视频会议 +NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn +NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn + # 生产环境跨站 SSO(本地不设置) # AUTH_COOKIE_DOMAIN=.hackrobot.cn diff --git a/.env.local b/.env.local index cbe1acb..26a9f62 100644 --- a/.env.local +++ b/.env.local @@ -12,11 +12,20 @@ PAYMENT_JOIN_AMOUNT=100 NEXT_PUBLIC_SITE_ID=meetup NEXT_PUBLIC_SITE_URL=https://nomadro.cn NEXT_PUBLIC_API_BASE_URL=/api +NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn +NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn # MinIO -MINIO_ENDPOINT=minio.nomadro.cn -MINIO_PORT=443 -MINIO_USE_SSL=true +MINIO_ENDPOINT=127.0.0.1 +MINIO_PORT=9100 +MINIO_USE_SSL=false +MINIO_BUCKET=hackrobot +MINIO_PUBLIC_URL=https://minio.nomadro.cn/hackrobot +NEXT_PUBLIC_MINIO_PUBLIC_URL=https://minio.nomadro.cn/hackrobot +MINIO_UPLOAD_PREFIX=nomadcna +MINIO_ACCESS_KEY=nomadro +MINIO_SECRET_KEY=Xiao4669805@ +MINIO_REGION=china # PocketBase 管理员(check-user、ensure-user、vip 需要) POCKETBASE_EMAIL=xiaoshuang.eric@gmail.com diff --git a/README.md b/README.md index 65308a1..7dff968 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,618 @@ # 游牧中国 NomadCNA -数字游民生活操作系统 (Nomad OS) - 你的下一站,由数据决定 +NomadCNA 是面向数字游民的生活操作系统,用一个站点承载城市决策、会员加入、线上/线下活动、视频会议、Web 群聊、内容资源、任务赏金、支付和媒体存储等能力。 -## 项目简介 +生产站点: -NomadCNA 是一个面向数字游民的站式生活平台,提供城市决策、税务签证、智能匹配、赏金任务等功能。从 Free 到 Pro,开启你的游牧人生。 +- 主站:https://nomadro.cn +- API:https://api.nomadro.cn +- PocketBase:https://pocketbase.nomadro.cn +- MinIO:https://minio.nomadro.cn +- MiroTalk P2P 视频房:https://mirotalk.nomadro.cn +- Web 群聊:https://lounge.nomadro.cn +- IRC 服务名:https://ergo.nomadro.cn + +## 业务介绍 + +NomadCNA 的目标用户是正在中国或亚洲范围内长期旅居、远程工作、寻找社群和机会的数字游民。业务上分为以下几条主线: + +| 模块 | 说明 | +| --- | --- | +| 城市探索 | 展示中国数字游民城市数据,包括生活成本、网络、温度、社群密度、安全、文化等指标。 | +| 下一站决策 | 根据预算、网络、气候、标签偏好生成城市推荐。 | +| 线上/线下活动 | 支持公开、登录成员、VIP、主持人等权限层级;线上和混合活动可进入 MiroTalk 视频房。 | +| Web 群聊 | 使用 The Lounge + Ergo IRC Server 提供数字游民 Web 群组聊天,活动参与人可进入群聊。 | +| 会员加入与支付 | 支持加入表单、支付订单、支付状态轮询、VIP/会员写入。 | +| Digital 子站 | `/digital` 是内容专题站,包含数字游民指南、课程、电子书、资源、工作机会、个人资料等。 | +| 媒体与内容 | 支持图片/视频上传、远程媒体代理缓存、内容投稿、博客、电子书、视频等内容页面。 | +| 社区与增长 | 包含讨论、交友匹配、任务赏金、反馈、通知、站内消息、服务线索。 | ## 技术栈 -- **前端框架**: Next.js 16 (App Router) -- **UI 框架**: React 19 + Tailwind CSS 4 -- **样式**: Tailwind CSS -- **字体**: Geist (Vercel) -- **数据库**: PocketBase -- **对象存储**: MinIO -- **语言**: TypeScript +| 层级 | 技术 | +| --- | --- | +| Web 前端 | Next.js 16 App Router、React 19、TypeScript、Tailwind CSS 4 | +| API | Next.js Route Handlers、FastAPI、PocketBase SDK/HTTP | +| 数据 | PocketBase、静态数据、MinIO 对象存储 | +| 支付 | ZPay / XorPay 适配层 | +| 视频会议 | MiroTalk P2P,Nginx HTTPS 反代 | +| Web 群聊 | The Lounge 4.5.0 + Ergo IRC Server 2.18.0 | +| 部署 | systemd、Nginx、Certbot、Docker | +| 包管理 | pnpm 10.25.0 | -## 功能特性 +生产服务器当前 Node.js 版本为 `v22.13.1`。 -- 🌏 **城市探索** - 发现最适合数字游民的中国城市 -- 📍 **城市数据** - 成本、生活质量、网络速度等数据对比 -- 🎯 **智能匹配** - 根据个人偏好推荐适合的城市 -- 🤝 **社区聚会** - 与各地游民建立联系 -- 💬 **社区互动** - 论坛、话题讨论 -- 🛠️ **实用工具** - 汇率、天气等实用工具 -- 🌙 **暗色模式** - 支持亮色/暗色主题切换 -- 🌐 **国际化** - 支持中文/英文 +## 源码解析 -## 快速开始 +### 顶层结构 + +```text +. +├── app/ # Next.js App Router 主目录 +│ ├── [locale]/ # 中文/英文路由,默认 zh +│ ├── api/ # Next.js Route Handlers +│ ├── components/ # 主站通用组件 +│ ├── context/ # Locale、Theme 等上下文 +│ ├── data/ # 城市、地图、mock 数据 +│ ├── digital/ # Digital 子站组件、配置、数据和工具 +│ └── lib/ # API、支付、媒体、活动、PocketBase 等工具 +├── backend/ # FastAPI 后端 +├── config/ # 主站和 Digital 子站配置 +├── i18n/ # 路由和国际化导航封装 +├── messages/ # zh/en 文案 +├── public/ # 静态资源 +├── cnomadcna.sh # 生产 Next.js 启动脚本 +├── next.config.ts # Next.js 配置与 API rewrites +├── package.json # 前端依赖与脚本 +└── tsconfig.json # TypeScript 配置 +``` + +### Next.js 页面结构 + +主站所有面向用户的页面位于 `app/[locale]`,路由强制带语言前缀: + +- `/zh`、`/en`:首页与城市探索 +- `/zh/dashboard`:下一站决策仪表盘 +- `/zh/meetups`:线上/线下活动,含 MiroTalk 视频房和 Web 群聊入口 +- `/zh/meetups/host`:活动发布页 +- `/zh/dating`:社区交友/匹配 +- `/zh/discuss`:讨论 +- `/zh/gigs`:任务赏金 +- `/zh/tools`:工具箱 +- `/zh/report`、`/zh/report/data`:统计与年报 +- `/zh/digital`:Digital 子站入口 + +通用布局与组件: + +- `app/layout.tsx`:根布局 +- `app/[locale]/layout.tsx`:语言布局 +- `app/components/Navbar.tsx`、`Footer.tsx`、`Sidebar.tsx`:主站导航与外壳 +- `app/context/LocaleContext.tsx`:运行时文案读取 +- `app/context/ThemeContext.tsx`:亮色/暗色主题 + +### Digital 子站 + +Digital 子站代码集中在 `app/digital` 与 `app/[locale]/digital`: + +- `app/[locale]/digital/page.tsx`:专题首页 +- `app/[locale]/digital/course`:课程页 +- `app/[locale]/digital/course/[moduleIndex]/[lessonIndex]`:课程章节 +- `app/[locale]/digital/ebook`:电子书 +- `app/[locale]/digital/jobs`:远程工作 +- `app/[locale]/digital/join`:加入/申请 +- `app/[locale]/digital/profile`:用户资料 + +配置在 `config/digital`: + +- `site.config.ts`:专题主题、功能开关、课程/电子书/加入等能力 +- `services.config.ts`:PocketBase、支付、MinIO 配置读取 +- `domain.config.ts`:域名和服务地址推导 +- `course.ts`、`lessons.ts`:课程数据 + +### API 分层 + +本项目有两层 API: + +1. Next.js 内置 API:`app/api/*` +2. FastAPI 后端:`backend/main.py` + +`next.config.ts` 将大多数 `/api/*` 转发到 FastAPI: + +```ts +source: "/api/:path((?!(?:digital|media)(?:$|/.*)).*)" +destination: "http://127.0.0.1:8000/api/:path*" +``` + +因此: + +- `/api/digital/*` 由 Next.js Route Handlers 处理 +- `/api/media/*` 由 Next.js Route Handlers 处理 +- 其他 `/api/*` 默认转发到 FastAPI + +### Next.js API + +| 路径 | 作用 | +| --- | --- | +| `app/api/digital/auth/*` | Digital 子站登录、退出、当前用户、注册、跨站同步 | +| `app/api/digital/join` | Digital 加入/申请表单写入 PocketBase | +| `app/api/digital/pay/*` | Digital 支付创建、确认、状态查询 | +| `app/api/digital/upload-media` | 媒体上传到 MinIO | +| `app/api/digital/vip/check` | VIP 状态检查 | +| `app/api/media/proxy` | 远程媒体抓取、校验、缓存到 MinIO 后跳转公开 URL | + +### FastAPI 后端 + +FastAPI 入口为 `backend/main.py`,配置读取在 `backend/settings.py`。核心能力: + +| API | 作用 | +| --- | --- | +| `/api/health` | 健康检查 | +| `/api/auth/email`、`/api/auth/google`、`/api/auth/me` | 主站认证 | +| `/api/join`、`/api/join/status` | 加入表单与状态 | +| `/api/upload-media` | 后端媒体上传 | +| `/api/pay`、`/api/pay/status` | 支付创建与查询 | +| `/cnomadcna/*_notify` | 支付网关回调 | +| `/api/cities`、`/api/cities/{slug}` | 城市数据 | +| `/api/content/*` | 首页内容、内容投稿、内容详情 | +| `/api/notifications/*` | 通知、回执、偏好 | +| `/api/recommendations/next-stop` | 下一站推荐 | +| `/api/meetups` | 活动列表与活动创建 | +| `/api/discussions` | 讨论列表与创建 | +| `/api/gigs` | 任务赏金列表、创建、申请 | +| `/api/profiles`、`/api/matches/swipes` | 资料与匹配 | +| `/api/feedback` | 用户反馈 | +| `/api/stats/*` | 成员地图、概览、城市排行 | +| `/api/services`、`/api/services/leads` | 增值服务与线索 | +| `/api/ai/assistant` | AI 助手占位接口 | + +后端辅助模块: + +- `backend/pb_client.py`:PocketBase HTTP 客户端 +- `backend/payment.py`:ZPay / XorPay 支付适配 +- `backend/minio_storage.py`:MinIO 上传 +- `backend/init_db.py`、`seed_data.py`、`enhanced_seed.py`:初始化与种子数据 +- `backend/settings.py`:环境变量读取 + +### 活动、视频与群聊源码 + +活动相关逻辑主要在: + +- `app/[locale]/meetups/page.tsx` +- `app/[locale]/meetups/host/page.tsx` +- `app/lib/meetups.ts` + +`app/lib/meetups.ts` 负责: + +- MiroTalk 默认地址:`https://mirotalk.nomadro.cn` +- MiroTalk 房间名规范化 +- MiroTalk 入会链接生成 +- Lounge 默认地址:`https://lounge.nomadro.cn` +- Web 群聊服务标签:`The Lounge + Ergo IRC` + +线上/混合活动详情页会展示: + +- MiroTalk P2P 视频入口 +- 页面内 iframe 打开视频房 +- The Lounge Web 群聊入口 +- 信令/中继服务器和群聊入口域名 + +### 配置与国际化 + +| 文件 | 作用 | +| --- | --- | +| `config/site.config.ts` | 主站 SEO 与站点基础信息 | +| `config/nav.config.ts` | 顶部导航 | +| `config/footer.config.ts` | 页脚 | +| `config/services.config.ts` | 主站支付配置 | +| `config/runtime-env.ts` | 运行环境判断 | +| `config/digital/*` | Digital 子站主题、服务、课程、域名配置 | +| `messages/zh.json`、`messages/en.json` | 中英文文案 | +| `i18n/routing.ts` | 语言列表与默认语言 | +| `i18n/navigation.ts` | 语言路由 Link / Router 封装 | + +## 本地开发 ### 环境要求 -- Node.js 18+ -- npm / yarn / pnpm / bun +- Node.js 20+,生产当前使用 Node.js 22.13.1 +- pnpm 10+ +- Python 3.12+ +- PocketBase +- 可选:MinIO、ZPay/XorPay 测试配置 ### 安装依赖 ```bash -npm install +pnpm install ``` -### 开发模式 +### 前端开发 ```bash -npm run dev +pnpm dev ``` -打开 [http://localhost:3000](http://localhost:3000) 查看项目。 +默认启动在: -### 生产构建 +```text +http://localhost:3001 +``` + +### FastAPI 后端开发 ```bash -npm run build -npm start +python3 -m venv backend/.venv +source backend/.venv/bin/activate +pip install -r backend/requirements.txt +uvicorn backend.main:app --host 127.0.0.1 --port 8000 --reload ``` -## 项目结构 +### 构建与启动 -``` -├── app/ # Next.js App Router -│ ├── components/ # React 组件 -│ ├── context/ # React Context (主题等) -│ ├── lib/ # 工具库 -│ └── [locale]/ # 国际化路由 -├── config/ # 配置文件 -│ ├── site.config.ts # 站点配置 -│ ├── nav.config.ts # 导航配置 -│ ├── footer.config.ts # 页脚配置 -│ └── ... -├── public/ # 静态资源 -└── i18n/ # 国际化配置 +```bash +pnpm build +pnpm start ``` -## 主要页面 +### 常用检查 -- `/` - 首页 (城市探索) -- `/about` - 关于我们 -- `/meetups` - 聚会活动 -- `/dating` - 社区交友 -- `/tools` - 实用工具 -- `/ai` - AI 助手 -- `/gigs` - 赏金任务 -- `/pricing` - 会员价格 -- `/join` - 加入社区 +```bash +pnpm lint +pnpm exec tsc --noEmit --pretty false +APP_ENV=production NEXT_PUBLIC_APP_ENV=production NODE_ENV=production pnpm build +``` -## 配置 +## 环境变量 -### 主题配置 +不要把真实密钥提交到 Git。`.env.example` 只放示例值,生产值放 `.env.local`、`backend/.env` 或 systemd Environment。 -主题相关逻辑位于 `app/context/ThemeContext.tsx`,支持: -- 手动切换亮色/暗色模式 -- 记住用户偏好 (localStorage) -- 跟随系统设置 +### 前端公开变量 -### 导航配置 +| 变量 | 说明 | +| --- | --- | +| `APP_ENV` | `development` / `production` | +| `NEXT_PUBLIC_APP_ENV` | 浏览器可见运行环境 | +| `NEXT_PUBLIC_SITE_URL` | 站点根地址 | +| `NEXT_PUBLIC_SITE_ID` | 站点 ID,用于会员/VIP 隔离 | +| `NEXT_PUBLIC_API_BASE_URL` | API 基础地址,生产通常为 `/api` | +| `NEXT_PUBLIC_POCKETBASE_URL` | 浏览器可访问 PocketBase 地址 | +| `NEXT_PUBLIC_MIROTALK_URL` | MiroTalk P2P 服务地址 | +| `NEXT_PUBLIC_LOUNGE_URL` | The Lounge Web 群聊地址 | +| `NEXT_PUBLIC_MINIO_PUBLIC_URL` | MinIO 文件公开地址 | -导航链接位于 `config/nav.config.ts`,可自定义修改。 +### 后端和服务端变量 -### 页脚配置 +| 变量 | 说明 | +| --- | --- | +| `POCKETBASE_URL` | 服务端访问 PocketBase 的内网地址 | +| `POCKETBASE_EMAIL` | PocketBase 管理员邮箱 | +| `POCKETBASE_PASSWORD` | PocketBase 管理员密码 | +| `PAYMENT_API_URL` | 支付 API 地址 | +| `PAYMENT_PROVIDER` | `zpay` 或 `xorpay` | +| `PAYMENT_DEFAULT_AMOUNT` | 默认支付金额 | +| `PAYMENT_JOIN_AMOUNT` | 加入场景金额 | +| `PAYMENT_JOIN_TYPE` | 加入场景订单类型 | +| `ZPAY_PID`、`ZPAY_KEY` | ZPay 商户配置 | +| `XORPAY_AID`、`XORPAY_SECRET` | XorPay 商户配置 | +| `MINIO_ENDPOINT` | MinIO 内网端点 | +| `MINIO_PORT` | MinIO 端口 | +| `MINIO_USE_SSL` | 是否对 MinIO 内网连接启用 SSL | +| `MINIO_BUCKET` | 存储桶 | +| `MINIO_ACCESS_KEY` | MinIO Access Key | +| `MINIO_SECRET_KEY` | MinIO Secret Key | +| `MINIO_REGION` | MinIO 区域 | +| `MINIO_UPLOAD_PREFIX` | 上传路径前缀 | +| `FRONTEND_ORIGINS` | FastAPI CORS 白名单 | +| `UPLOAD_DIR` | 后端本地上传目录 | -页脚链接位于 `config/footer.config.ts`,可自定义修改。 +## 生产服务器支持 -## 贡献指南 +生产代码目录: -欢迎提交 Pull Request 和 Issue! +```text +/home/ubuntu/gitlab-instance-0a899031_cnomadcna +``` -## 许可证 +### 域名与服务映射 -MIT License +| 域名 | 上游 | 说明 | +| --- | --- | --- | +| `nomadro.cn` / `www.nomadro.cn` | `127.0.0.1:4002` | Next.js 主站,`cnomadcna.service` | +| `api.nomadro.cn` | `127.0.0.1:8000` | FastAPI | +| `pocketbase.nomadro.cn` | `127.0.0.1:8090` | PocketBase | +| `minio.nomadro.cn` | `127.0.0.1:9100` / `9101` | MinIO API / Console | +| `mirotalk.nomadro.cn` | `127.0.0.1:9300` | MiroTalk P2P | +| `lounge.nomadro.cn` | `127.0.0.1:9000` | The Lounge Web IRC | +| `ergo.nomadro.cn` | HTTPS redirect | 跳转到 `lounge.nomadro.cn`;IRC 后端只内网监听 | +| `ntfy.nomadro.cn` | `127.0.0.1:9180` | ntfy 通知服务 | + +### systemd 服务 + +| 服务 | 作用 | 当前策略 | +| --- | --- | --- | +| `cnomadcna.service` | Next.js 生产服务 | enabled,监听 `0.0.0.0:4002` | +| `mirotalk-p2p.service` | MiroTalk P2P | enabled,监听 `127.0.0.1:9300` | +| `ergo.service` | Ergo IRC Server | enabled,监听 `127.0.0.1:6667` 和 `[::1]:6667` | +| `thelounge.service` | The Lounge Web IRC | enabled,监听 `127.0.0.1:9000` | + +常用命令: + +```bash +systemctl status cnomadcna.service +systemctl status mirotalk-p2p.service +systemctl status ergo.service +systemctl status thelounge.service + +journalctl -u cnomadcna.service -n 100 --no-pager +journalctl -u mirotalk-p2p.service -n 100 --no-pager +journalctl -u ergo.service -n 100 --no-pager +journalctl -u thelounge.service -n 100 --no-pager +``` + +### Docker 服务 + +当前生产容器: + +| 容器 | 镜像 | 端口 | +| --- | --- | --- | +| `nomadro-minio` | `minio/minio:latest` | `127.0.0.1:9100->9000`,`127.0.0.1:9101->9001` | +| `nomadro-ntfy` | `binwiederhier/ntfy:latest` | `127.0.0.1:9180->80` | + +检查: + +```bash +docker ps +docker logs nomadro-minio --tail 100 +docker logs nomadro-ntfy --tail 100 +``` + +### Nginx + +Nginx vhost 目录: + +```text +/www/server/panel/vhost/nginx +``` + +关键配置: + +- `www.nomadro.cn.conf`:主站 +- `api.nomadro.cn.conf`:FastAPI +- `mirotalk.nomadro.cn.conf`:MiroTalk +- `lounge.nomadro.cn.conf`:The Lounge + Ergo redirect +- `minio.nomadro.cn.conf`:MinIO +- `pocketbase.nomadro.cn.conf`:PocketBase + +常用命令: + +```bash +nginx -t +nginx -s reload +``` + +### HTTPS 证书 + +证书由 Certbot 管理。`lounge.nomadro.cn` 证书包含两个域名: + +```text +lounge.nomadro.cn +ergo.nomadro.cn +``` + +当前证书路径: + +```text +/etc/letsencrypt/live/lounge.nomadro.cn/fullchain.pem +/etc/letsencrypt/live/lounge.nomadro.cn/privkey.pem +``` + +检查: + +```bash +certbot certificates +``` + +### MiroTalk P2P + +安装目录: + +```text +/opt/nomadro-services/mirotalk +``` + +运行方式: + +- systemd:`mirotalk-p2p.service` +- 内网端口:`127.0.0.1:9300` +- 公网入口:`https://mirotalk.nomadro.cn` +- 活动页自动生成链接:`https://mirotalk.nomadro.cn/join?room=...&name=...` + +健康检查: + +```bash +curl -I https://mirotalk.nomadro.cn/join?room=nomadcna-healthcheck +``` + +### Ergo IRC Server + +安装目录: + +```text +/opt/ergo +``` + +配置: + +```text +/etc/ergo/ircd.yaml +/etc/ergo/ergo.motd +/var/lib/ergo/ircd.db +``` + +当前策略: + +- 网络名:`NomadCNA` +- 服务名:`ergo.nomadro.cn` +- 只监听本机 `127.0.0.1:6667` 和 `[::1]:6667` +- 不开放公网 IRC TLS `6697` +- `ergo.nomadro.cn` 的 HTTPS 请求跳转到 The Lounge + +本机 IRC 握手检查: + +```bash +{ printf 'NICK health\r\nUSER health 0 * :NomadCNA Health\r\nJOIN #nomadcna\r\nQUIT :healthcheck\r\n'; sleep 1; } \ + | timeout 5s nc 127.0.0.1 6667 +``` + +### The Lounge Web 群聊 + +安装目录: + +```text +/opt/nomadro-services/thelounge +``` + +配置: + +```text +/var/lib/thelounge/config.js +``` + +当前策略: + +- public mode +- 只监听 `127.0.0.1:9000` +- Nginx 通过 `https://lounge.nomadro.cn` 反代 +- 默认网络锁定到本机 Ergo:`127.0.0.1:6667` +- 默认加入频道:`#nomadcna,#online-events` +- 文件上传关闭 +- The Lounge 自身消息持久化关闭,Ergo 保留内存历史 + +健康检查: + +```bash +curl -I https://lounge.nomadro.cn/ +curl 'https://lounge.nomadro.cn/socket.io/?EIO=4&transport=polling&t=health' +``` + +## 上线流程 + +### 前端上线 + +```bash +cd /home/ubuntu/gitlab-instance-0a899031_cnomadcna +APP_ENV=production NEXT_PUBLIC_APP_ENV=production NODE_ENV=production pnpm build +systemctl restart cnomadcna.service +systemctl status cnomadcna.service --no-pager +``` + +### 后端上线 + +FastAPI 当前运行在 `127.0.0.1:8000`。更新后需重启对应 uvicorn 进程或其 systemd 服务。如果后续为 FastAPI 增加 systemd 单元,建议命名为: + +```text +cnomadcna-api.service +``` + +### Nginx 配置变更 + +```bash +nginx -t +nginx -s reload +``` + +### 服务健康检查 + +```bash +curl -I https://nomadro.cn/zh/meetups +curl -I https://api.nomadro.cn/api/health +curl -I https://mirotalk.nomadro.cn/join?room=nomadcna-healthcheck +curl -I https://lounge.nomadro.cn/ +curl -I https://ergo.nomadro.cn/ +``` + +## 数据与备份 + +建议纳入备份的内容: + +| 路径/服务 | 说明 | +| --- | --- | +| `.env.local`、`backend/.env` | 生产环境变量和密钥 | +| PocketBase 数据目录 | 用户、会员、订单、内容、活动等业务数据 | +| MinIO 数据卷 | 上传媒体、远程媒体缓存 | +| `/var/lib/ergo` | Ergo IRC 数据库 | +| `/var/lib/thelounge` | The Lounge 配置、VAPID key、包配置 | +| `/opt/nomadro-services/mirotalk` | MiroTalk 源码部署目录 | +| `/www/server/panel/vhost/nginx/*.conf` | Nginx vhost 配置 | +| `/etc/systemd/system/*.service` | systemd 单元 | + +## 安全注意事项 + +- README 不应包含真实密钥、管理员密码、支付密钥或 MinIO Secret。 +- `.env.local` 和 `backend/.env` 不应提交到 Git。 +- The Lounge 当前为 public mode,适合活动公开群聊;如需长期身份和历史,可切换 private mode 并创建用户账号。 +- Ergo 原生 IRC 公网端口当前不开放,这是有意设计;需要原生 IRC 客户端时再开放 `6697` 并配置 TLS。 +- 内部服务优先绑定 `127.0.0.1`,统一通过 Nginx HTTPS 暴露。 +- 修改 Nginx 前必须先 `nginx -t`。 +- 更新证书后确认 SAN 包含对应域名。 + +## 开发约定 + +- 新页面优先放入 `app/[locale]`,并补充 `messages/zh.json`、`messages/en.json`。 +- 需要语言感知链接时使用 `i18n/navigation.ts` 导出的 `Link`。 +- 调用后端 API 使用 `app/lib/api-client.ts`。 +- 主站通用配置放 `config/*`,Digital 子站配置放 `config/digital/*`。 +- 媒体上传和远程媒体代理优先走 MinIO。 +- 支付相关逻辑优先复用 `app/lib/payment`、`backend/payment.py` 和 `config/*/services.config.ts`。 +- 线上活动相关逻辑优先复用 `app/lib/meetups.ts`。 + +## 常见问题 + +### 为什么 `ergo.nomadro.cn` 不直接打开 IRC? + +`ergo.nomadro.cn` 是 IRC 服务名,但原生 IRC 端口没有对公网开放。用户从浏览器进入 `lounge.nomadro.cn`,The Lounge 在服务器本机连接 Ergo。这样能减少公网攻击面,同时满足 Web 群聊需求。 + +### 为什么 `/api/digital/*` 没有转发到 FastAPI? + +`next.config.ts` 明确排除了 `digital` 和 `media` 两类 API,它们由 Next.js Route Handlers 直接处理。其他 `/api/*` 才会转发到 FastAPI。 + +### 活动视频房如何生成? + +`app/lib/meetups.ts` 会根据活动城市、日期、时间和 ID 生成 MiroTalk room slug。如果活动有自定义 `meetingUrl`,则优先使用自定义链接。 + +### 群聊频道如何进入? + +The Lounge public mode 的默认网络已锁定到 Ergo,并默认加入 `#nomadcna` 和 `#online-events`。活动详情页的“进入群聊”按钮指向 `https://lounge.nomadro.cn`。 + +## 维护命令速查 + +```bash +# 构建并重启主站 +APP_ENV=production NEXT_PUBLIC_APP_ENV=production NODE_ENV=production pnpm build +systemctl restart cnomadcna.service + +# 查看服务 +systemctl status cnomadcna.service --no-pager +systemctl status mirotalk-p2p.service --no-pager +systemctl status ergo.service --no-pager +systemctl status thelounge.service --no-pager + +# 查看日志 +journalctl -u cnomadcna.service -n 100 --no-pager +journalctl -u mirotalk-p2p.service -n 100 --no-pager +journalctl -u ergo.service -n 100 --no-pager +journalctl -u thelounge.service -n 100 --no-pager + +# Nginx +nginx -t +nginx -s reload + +# 端口 +ss -ltnp | rg ':4002|:8000|:8090|:9000|:9300|:9100|:9101|:6667|:6697' + +# Docker +docker ps + +# HTTPS +certbot certificates +``` diff --git a/app/[locale]/app/page.tsx b/app/[locale]/app/page.tsx index ab927f5..8528833 100644 --- a/app/[locale]/app/page.tsx +++ b/app/[locale]/app/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { Link, useLocale } from "@/i18n/navigation"; import { apiFetch } from "@/app/lib/api-client"; import Footer from "@/app/components/Footer"; +import { mediaUrl } from "@/app/lib/media"; interface ContentItem { title: string; @@ -46,7 +47,7 @@ export default function AppLandingPage() { const title = item ? (locale === "zh" ? item.title : item.titleEn || item.title) : "NomadCNA App"; const subtitle = item ? (locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle) : ""; const description = item ? (locale === "zh" ? item.description : item.descriptionEn || item.description) : ""; - const downloadUrl = normalizeDownloadUrl(item?.mediaUrl); + const downloadUrl = mediaUrl(normalizeDownloadUrl(item?.mediaUrl)); return (
@@ -68,7 +69,7 @@ export default function AppLandingPage() {
- {item?.coverImage && {title}} + {item?.coverImage && {title}}

今日推荐

大理 · 慢生活路线

diff --git a/app/[locale]/blog/page.tsx b/app/[locale]/blog/page.tsx index fc4623c..a34722d 100644 --- a/app/[locale]/blog/page.tsx +++ b/app/[locale]/blog/page.tsx @@ -1,5 +1,6 @@ import Footer from "@/app/components/Footer"; import { blogPosts } from "@/app/data/mock"; +import { mediaUrl } from "@/app/lib/media"; export const metadata = { title: "博客 | 游牧中国", @@ -29,7 +30,7 @@ export default function BlogPage() {
{featuredPost.title} @@ -46,7 +47,7 @@ export default function BlogPage() {

{featuredPost.author} @@ -74,7 +75,7 @@ export default function BlogPage() { >
{post.title} @@ -91,7 +92,7 @@ export default function BlogPage() {

{post.author} diff --git a/app/[locale]/dating/page.tsx b/app/[locale]/dating/page.tsx index 2d39605..dd462d9 100644 --- a/app/[locale]/dating/page.tsx +++ b/app/[locale]/dating/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useTranslation } from "@/i18n/navigation"; import Footer from "@/app/components/Footer"; import { apiFetch } from "@/app/lib/api-client"; +import { mediaUrl } from "@/app/lib/media"; type TabType = "friends" | "dating" | "partner" | "roommate" | "cofounder" | "explore"; @@ -216,7 +217,7 @@ export default function DatingPage() {
{matches.map((match) => (
- {match.name} + {match.name}

{match.name}

{match.location}

@@ -266,7 +267,7 @@ export default function DatingPage() {
- {currentProfile.name} + {currentProfile.name}

{currentProfile.name},{currentProfile.age}

diff --git a/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx b/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx index 2d9ee72..d8928d7 100644 --- a/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx +++ b/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx @@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; import "highlight.js/styles/atom-one-dark.min.css"; import styles from "./lesson-markdown.module.css"; +import { mediaUrl } from "@/app/lib/media"; type TocItem = { level: number; text: string; id: string }; @@ -120,7 +121,7 @@ export function LessonMarkdown({ content }: { content: string }) { ), img: ({ src, alt }) => ( // eslint-disable-next-line @next/next/no-img-element - {alt + {alt ), table: ({ children }) => (
diff --git a/app/[locale]/discuss/page.tsx b/app/[locale]/discuss/page.tsx index b8703b6..828da6a 100644 --- a/app/[locale]/discuss/page.tsx +++ b/app/[locale]/discuss/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "@/i18n/navigation"; import Footer from "@/app/components/Footer"; import { apiFetch } from "@/app/lib/api-client"; +import { mediaUrl } from "@/app/lib/media"; interface Topic { id: string; @@ -296,7 +297,7 @@ export default function DiscussPage() { >
{topic.author} diff --git a/app/[locale]/ebooks/[slug]/page.tsx b/app/[locale]/ebooks/[slug]/page.tsx index 035517c..8c112bb 100644 --- a/app/[locale]/ebooks/[slug]/page.tsx +++ b/app/[locale]/ebooks/[slug]/page.tsx @@ -5,6 +5,7 @@ import { Link, useLocale } from "@/i18n/navigation"; import { apiFetch } from "@/app/lib/api-client"; import Footer from "@/app/components/Footer"; import ContentSubmissionModal from "@/app/components/ContentSubmissionModal"; +import { mediaUrl } from "@/app/lib/media"; interface ContentItem { slug: string; @@ -58,7 +59,7 @@ export default function EbookPage({ params }: { params: Promise<{ slug: string }
- {title} + {title}

电子书

@@ -80,7 +81,7 @@ export default function EbookPage({ params }: { params: Promise<{ slug: string }
{item.mediaUrl ? ( - + 下载电子书 ) : ( diff --git a/app/[locale]/meetups/host/page.tsx b/app/[locale]/meetups/host/page.tsx index 66ea740..48b60fa 100644 --- a/app/[locale]/meetups/host/page.tsx +++ b/app/[locale]/meetups/host/page.tsx @@ -1,18 +1,107 @@ "use client"; -import { useState } from "react"; -import { Link, useTranslation } from "@/i18n/navigation"; +import { useMemo, useState, type FormEvent } from "react"; +import { Link, useLocale, useTranslation } from "@/i18n/navigation"; import { cities } from "@/app/data/cities"; import { apiFetch } from "@/app/lib/api-client"; +import { + createMiroTalkRoomSlug, + DEFAULT_ONLINE_FEATURES, + getMiroTalkServerLabel, + MIROTALK_PROVIDER_LABEL, + MEETUP_FEATURE_OPTIONS, + normalizeMiroTalkRoom, + type MeetupAccessLevel, + type MeetupMode, +} from "@/app/lib/meetups"; const CITY_OPTIONS = cities.map((c) => ({ value: c.name, label: `${c.icon} ${c.name}` })); const CITY_BY_NAME = new Map(cities.map((city) => [city.name, city])); +interface HostMeetupForm { + mode: MeetupMode; + city: string; + date: string; + time: string; + venue: string; + address: string; + description: string; + maxAttendees: string; + organizer: string; + accessLevel: MeetupAccessLevel; + meetingProvider: string; + mirotalkRoom: string; + meetingUrl: string; + features: string[]; +} + +const copy = { + zh: { + activityType: "活动形式", + offline: "线下", + online: "线上", + hybrid: "混合", + organizer: "主办方", + organizerPlaceholder: "如:NomadCNA / 城市主理人", + onlineSettings: "线上活动配置", + provider: "视频服务", + accessLevel: "访问权限", + publicAccess: "公开", + membersAccess: "登录成员", + vipAccess: "VIP 成员", + hostsAccess: "主持人", + room: "MiroTalk 房间名", + roomPlaceholder: "留空时自动生成", + roomSuggestion: "将使用房间", + meetingUrl: "自定义入会链接", + meetingUrlPlaceholder: "可选,例如 https://mirotalk.nomadro.cn/join?room=...", + features: "开放功能", + onlineHint: "线上/混合活动会保存 provider、room、accessLevel、features 等字段,并默认通过自建 MiroTalk P2P 服务生成入会入口。", + defaultServer: "默认服务", + pendingHint: "提交后会进入本机 PocketBase 的 meetups 表,审核通过前状态为 pending。", + submitOnline: "发布活动", + crossCity: "线上 / 跨城市", + }, + en: { + activityType: "Event type", + offline: "Offline", + online: "Online", + hybrid: "Hybrid", + organizer: "Organizer", + organizerPlaceholder: "e.g. NomadCNA / city host", + onlineSettings: "Online event settings", + provider: "Video service", + accessLevel: "Access level", + publicAccess: "Public", + membersAccess: "Members", + vipAccess: "VIP members", + hostsAccess: "Hosts", + room: "MiroTalk room", + roomPlaceholder: "Auto-generated when empty", + roomSuggestion: "Room to use", + meetingUrl: "Custom join URL", + meetingUrlPlaceholder: "Optional, e.g. https://mirotalk.nomadro.cn/join?room=...", + features: "Enabled features", + onlineHint: "Online/hybrid events store provider, room, accessLevel, and features, then generate join links through the self-hosted MiroTalk P2P service by default.", + defaultServer: "Default server", + pendingHint: "After submission, this writes to the local PocketBase meetups table with pending status.", + submitOnline: "Publish event", + crossCity: "Online / cross-city", + }, +}; + +function getCopy(locale: string) { + return locale === "zh" ? copy.zh : copy.en; +} + export default function HostMeetupPage() { const { t } = useTranslation("hostMeetup"); + const locale = useLocale(); + const c = getCopy(locale); const [submitted, setSubmitted] = useState(false); const [submitting, setSubmitting] = useState(false); - const [form, setForm] = useState({ + const [form, setForm] = useState({ + mode: "offline", city: "", date: "", time: "19:00", @@ -20,29 +109,78 @@ export default function HostMeetupPage() { address: "", description: "", maxAttendees: "20", + organizer: "", + accessLevel: "members", + meetingProvider: "mirotalk", + mirotalkRoom: "", + meetingUrl: "", + features: DEFAULT_ONLINE_FEATURES, }); - const handleSubmit = async (e: React.FormEvent) => { + const isOnlineMode = form.mode !== "offline"; + const isOnlineOnly = form.mode === "online"; + const city = CITY_BY_NAME.get(form.city); + const mirotalkServer = getMiroTalkServerLabel(); + const roomToUse = useMemo( + () => + normalizeMiroTalkRoom(form.mirotalkRoom) || + createMiroTalkRoomSlug({ + id: "new", + city: form.city || "online", + date: form.date, + time: form.time, + }), + [form.city, form.date, form.mirotalkRoom, form.time] + ); + + const setField = (field: K, value: HostMeetupForm[K]) => { + setForm((current) => ({ ...current, [field]: value })); + }; + + const toggleFeature = (feature: string) => { + setForm((current) => { + const selected = new Set(current.features); + if (selected.has(feature)) { + selected.delete(feature); + } else { + selected.add(feature); + } + return { ...current, features: Array.from(selected) }; + }); + }; + + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - const city = CITY_BY_NAME.get(form.city); setSubmitting(true); try { + const finalCity = isOnlineOnly && !form.city ? "线上" : form.city; + const finalVenue = isOnlineOnly && !form.venue ? "MiroTalk 多人视频房" : form.venue; + const finalAddress = isOnlineOnly && !form.address ? "线上活动" : form.address; await apiFetch("/api/meetups", { method: "POST", body: JSON.stringify({ - city: form.city, - emoji: city?.icon || "📍", + city: finalCity, + emoji: city?.icon || (isOnlineOnly ? "💻" : "📍"), date: form.date, time: form.time, - venue: form.venue, - address: form.address, + venue: finalVenue, + address: finalAddress, description: form.description, rsvpCount: 1, - gradientFrom: city?.gradientFrom || "#ff4d4f", - gradientTo: city?.gradientTo || "#ff7a45", + gradientFrom: city?.gradientFrom || (isOnlineMode ? "#0f766e" : "#ff4d4f"), + gradientTo: city?.gradientTo || (isOnlineMode ? "#2563eb" : "#ff7a45"), attendees: [], isUpcoming: true, status: "pending", + mode: form.mode, + maxAttendees: Number(form.maxAttendees || 0), + organizer: form.organizer.trim() || "NomadCNA", + accessLevel: form.accessLevel, + meetingProvider: isOnlineMode ? form.meetingProvider : "", + mirotalkRoom: isOnlineMode ? roomToUse : "", + meetingUrl: isOnlineMode ? form.meetingUrl.trim() : "", + replayUrl: "", + features: isOnlineMode ? form.features : [], }), }); setSubmitted(true); @@ -54,26 +192,26 @@ export default function HostMeetupPage() { if (submitted) { return (
-
-
- 🎉 -

+
+
+ 🎉 +

{t("successTitle")}

-

+

{t("successDesc")}

-
+
setSubmitted(false)} - className="px-6 py-3 rounded-xl border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" + className="cursor-pointer rounded-xl border border-gray-200 px-6 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800" > {t("createAnother")} {t("viewMeetups")} @@ -86,138 +224,267 @@ export default function HostMeetupPage() { return (
-
+
← {t("backToMeetups")} -

- +

+ {t("title")}

-

+

{t("subtitle")}

+
+ + {c.activityType} * + +
+ {([ + ["offline", c.offline], + ["online", c.online], + ["hybrid", c.hybrid], + ] as const).map(([value, label]) => ( + + ))} +
+
+
-
-
+
-
-
-
-
-