Compare commits

..

73 Commits
main ... dev2

Author SHA1 Message Date
eric
79dda36dd3 Ship Taro WeChat mini-app with H5-aligned UI and WeChat login.
Document wemini credentials and native weapp workflow in README; add jscode2session auth endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 22:05:56 -05:00
eric
2bcbc303d8 Fix redmini lifestyle/voices/FAQ to use real H5 DOM classes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 13:20:01 -05:00
eric
dbeac128bb Rebuild redmini as offline mirror of the existing H5 (same globals.css classes).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 13:14:28 -05:00
eric
bf7167c185 Rebuild redmini as full offline nomadro product mirror for XHS.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 13:04:35 -05:00
eric
73b0513b9c Add WeChat, Douyin, and offline XHS mini-program scaffolds.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 12:57:18 -05:00
eric
5c36032a46 Polish conversion trust: newsletter i18n, pay honesty, join funnel, Google login hook.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 10:30:11 -05:00
eric
2fa59f0cd4 Fix double arrow on about/static back-home links.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 09:51:46 -05:00
eric
863643aa33 Restore home lifestyle/voices with i18n and polish about/static CTAs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 09:48:20 -05:00
eric
ab9d8f6670 i18n day/blog/ebook thanks and polish community reply UX.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 09:38:14 -05:00
eric
1c44721062 Style visa wizard result action row.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 09:29:58 -05:00
eric
e23cf9a03c Close Explore/Grow loops: visa wizard, jobs, lessons, gigs notify, map.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 09:26:56 -05:00
eric
7d5ac47ee1 Tidy mobile live iframe height rules.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 09:02:53 -05:00
eric
67fd09341b Require RSVP for live rooms, add schedule banner, and chat unread badge.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 09:00:01 -05:00
eric
5d741df77b Fix live room iframe CSP blocking and add open-in-new-tab fallback.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 07:39:52 -05:00
eric
f9ab758926 Polish dating profile link, likes title, and offline RSVP calendar export.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 07:17:48 -05:00
eric
377db2284f Move chat and notifications out of Connect into top-right icons.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 07:12:04 -05:00
eric
a62cf057e8 Fix profile Connect stats TypeScript for mutual matches shape.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:53:48 -05:00
eric
cc05691e9a Route meetup RSVP to live/calendar and add Connect stats on profile.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:51:44 -05:00
eric
fe4f0f5983 Fix member profiles, chat peer header, and like-back CTAs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:43:48 -05:00
eric
5525d04744 Seed match likes/chats for new joiners and split likes received tab.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:30:25 -05:00
eric
681879b28e Add left/right/up drag swipes on dating match cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:23:57 -05:00
eric
bdf0516197 Fix dating match gate and expand seed data for usable demos.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:18:25 -05:00
eric
8ea3f622ed Polish discovery loop: ToolsStrip, map CTAs, meetup city filter, gig login gate.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 22:16:46 -05:00
eric
31ce4107f4 Fix nav i18n keys for services, feedback and AI.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:39:40 -05:00
eric
ea030275ce Polish three rings: connect auth gates, explore shortcuts, grow nav and forms.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:37:57 -05:00
eric
2ee0f3bf77 Polish edge UX: 404/error rings, sitemap, matcher empty, shortcuts and tips.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:24:54 -05:00
eric
dac99e8ef4 Polish map empty states, login hints, PWA install, live mobile.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:11:28 -05:00
eric
9f2eee7e83 Polish destination actions, submit, likes, newsletter honesty.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 20:58:10 -05:00
eric
0ce48023ae Polish explore hubs: feedback API, live gates, AI tips, empty states.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 20:45:58 -05:00
eric
d96666c329 Harden write flows and unlock digital VIP lessons client-side.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 20:31:29 -05:00
eric
d43005d04a Polish connect-ring UX: VIP, notifications, dating, gigs, chat.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 20:16:31 -05:00
eric
8b80d18006 Add avatar upload, harden payments, and ebook checkout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 19:55:05 -05:00
eric
f215b684f6 Wire connect-ring notifications and durable meetup RSVP.
Require login for meetup RSVP with cancel, notify organizers/authors/posters on RSVP/reply/gig/match, optional ntfy + Listmonk hooks, and honest newsletter messaging.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 19:33:07 -05:00
eric
47bbc7dd0f Move nav chrome to top-right and refresh README.
Cluster language, theme, and notifications in nav-utils on the right; document three-ring product IA and stack in README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 19:19:41 -05:00
eric
969a0b037a Continue progressive rings: search, onboard, plan hubs.
Empty search shows three-ring shortcuts, onboarding follows discover-belong-grow, and plan/compare/next-stop surface only the next linked steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 19:06:32 -05:00
eric
775f90dc6d Restructure features into linked rings instead of flat dumps.
Group nav into Explore/Connect/Grow, slim home and tools surfaces, and add progressive next-step CTAs across hubs so users follow one path at a time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 18:57:10 -05:00
eric
9b3f210bd2 Add city detail depth, enriched routes, and meetup auth flows.
Port NomadCNA city_details/content/routes seeding into PocketBase and surface full destination pages with related meetups and discussions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 11:54:10 -05:00
eric
5280c5f62a Fix native deploy to force-reset dirty server trees before checkout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 11:35:55 -05:00
eric
96fa96c61e Migrate auth/community/social to PocketBase and wire SeaweedFS S3 uploads.
Keep FastAPI as the only backend; add media upload, ntfy hook, and PB collection bootstrap for production.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 11:19:30 -05:00
eric
48408d3079 feat: complete NomadCNA parity layer with platform APIs and pages
Add services, videos, AI assistant, map/weather, gigs post, notifications settings, static legal pages, Google OAuth, payment router, real-user matching, newsletter, and content submission — all using nomadweb UI patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 10:13:17 -05:00
eric
ff7b721acb feat: NomadCNA community layer, social matching, payments, and ebook
Port meetups, discussions, gigs, dating/chat, VIP pay (ZPay/XorPay), MiroTalk live, digital academy, and ebook reader. Add persistent community_store, git-first deploy docs, and env template for production secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 09:38:13 -05:00
eric
99ebbed799 Refresh README for plan/compare sync, native deploy, and product funnel.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 05:02:29 -05:00
eric
8ab9b9b9a1 Polish discovery funnel: surface plan/compare, login next redirect, SEO metadata.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 04:17:50 -05:00
eric
11daabf065 Fix deferred sections staying invisible after lazy mount.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 03:58:21 -05:00
eric
f30a7cf879 Optimize first load: defer mid-page kits, idle chrome, lighter canvas, self-hosted Outfit.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 03:50:50 -05:00
eric
9a6474a69a Wire plan to real visas (CN stay keys) and add ICS calendar export.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 03:37:52 -05:00
eric
a1b2408ed0 Add account-synced trip plans with durable auth store and richer profile hub.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 03:10:29 -05:00
eric
fd87f765c8 Close discover-decide-plan loop: matcher to trip, shareable /compare hub.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 02:58:40 -05:00
eric
b6df330a18 Add /plan move hub: timeline, budget, visa stay hints, and readiness checklist.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 02:43:20 -05:00
eric
d857d5b9b2 Add 14 nomad tools in one batch: weekend, money, life, and safety kits.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 02:32:54 -05:00
eric
9b04246f0d Merge duplicate section CSS rules after perf tweaks.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 02:24:02 -05:00
eric
8a0ca181e7 Optimize homepage: defer below-fold mounts, lighter particles, faster loader.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 02:22:55 -05:00
eric
90abeac6f6 Add first-month landing cost and mobile data budget planners.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 02:16:45 -05:00
eric
45b4aaef53 Add visa stay countdown and cowork-vs-cafe cost comparator.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 02:09:55 -05:00
eric
2943f48035 Run Next.js via standalone server.js under systemd.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 02:02:42 -05:00
eric
e8d225390d Switch production deploy to native systemd instead of Docker.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 01:51:46 -05:00
eric
f6019e050f Harden deploy with flock and verify frontend-only incremental rebuilds.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 01:42:32 -05:00
eric
cc5b9ab1ad Stop ignoring deploy_update.py so deploy optimizations stay in sync.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 01:37:47 -05:00
eric
a1359d7b23 Track optimized deploy_update.py (was gitignored).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 01:37:15 -05:00
eric
871ea78bf8 Speed up deploys with BuildKit caches and incremental service rebuilds.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 01:36:33 -05:00
eric
402e2e950b Add daylight work window and bag weight estimator with polished UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 01:26:07 -05:00
eric
b4f1846d65 Add runway calculator and meeting golden-hour timeline with polished UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 01:12:33 -05:00
eric
2dd448874e Fix broken production CSS from invalid changelog bullet character.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:56:54 -05:00
eric
96d3d83906 Improve search, tools strip, trip MD export, lazy-load home sections
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:29:30 -05:00
eric
4ab2d7e412 Add tools hub, changelog, ATM guide, and slim section nav
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:24:44 -05:00
eric
04f8c14ed5 Add power plugs, emergency contacts, city notes, and focus timer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:18:39 -05:00
eric
916ce30733 Add tax day counter, arrival checklist, cafe guide, and onboarding
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:13:19 -05:00
eric
341fcdd15e Add insurance, expenses, SIM guide, about page; unify trip storage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:09:10 -05:00
eric
d1bc4af87f Add jet lag, housing guide, phrasebook, and feedback widget
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:28:52 -05:00
eric
cfc3588d7d Ignore local deploy helper scripts with credentials
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:25:23 -05:00
eric
18fb3318b1 Rebrand to nomadro and add wifi score, savings goal, daily quote
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:19:27 -05:00
eric
f13722123a Add spin globe, flight estimator, events calendar, and privacy page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:10:54 -05:00
eric
bf654f76e6 Add visa wizard, coworking, packing, season guide, and UX polish
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:05:48 -05:00
491 changed files with 101853 additions and 1207 deletions

28
.gitignore vendored
View File

@ -6,6 +6,21 @@ backend/**/__pycache__/
backend/.env
frontend/.env.local
# Auth / plan user store (runtime)
backend/app/data/user_store.json
backend/app/data/social_store.json
backend/app/data/community_store.json
backend/app/data/platform_store.json
# Production secrets (use deploy/nomadro-api.env.example)
deploy/nomadro-api.env
# Local screenshots / temp
book-*.png
tmp-screenshots/
scripts/_shell_out.txt
scripts/_*.py
# PocketBase
pocketbase/pb_data/*
!pocketbase/pb_data/.gitkeep
@ -14,6 +29,13 @@ pocketbase/pb_data/*
frontend/.next/
frontend/out/
# Mini programs
wemini/node_modules/
wemini/dist/
dymini/node_modules/
dymini/dist/
redmini/dist/
# Python
*.pyc
.venv/
@ -31,8 +53,14 @@ Thumbs.db
scripts/remote_deploy.py
scripts/redeploy_server.py
scripts/upload_and_deploy.py
# deploy_update.py is tracked; rotate credentials if repo is shared
scripts/rebuild_frontend.py
scripts/check_server.py
scripts/check_ports.py
scripts/find_caddy.py
scripts/read_caddy.py
scripts/verify_site.py
wemini/.swc/
wemini/project.private.config.json

321
README.md
View File

@ -1,63 +1,109 @@
# NomadFlow · 数字游民旅居平台
# nomadro · 数字游民旅居平台
全栈数字游民旅居指南,采用 **Next.js + FastAPI + PocketBase** 架构。
品牌:**NomadFlow** — 用一行代码环游世界 🌏
品牌 **nomadro** — 用一行代码环游世界 🌏
**线上:** [https://nomadweb.nomadro.com](https://nomadweb.nomadro.com)
## 产品路径(环环相扣)
功能很多,但**不一次全部展示**。导航与首页按三条环串联,每页只露出「接下来」:
| 环 | 做什么 | 主要入口 |
|----|--------|----------|
| **探索 Explore** | 发现去哪 | 目的地、地图、下一站、签证;(离首页后再出现计划 / 对比) |
| **连接 Connect** | 遇见同行 | 活动 → 社区 → 匹配 → 私信 |
| **成长 Grow** | 路上做事 | 学院 → 赏金 → 工具箱(按需)→ 电子书 |
```
发现城市 ──► 写入计划 / 同城活动 ──► 社区 / 匹配 ──► 学院 / 赏金
```
- 顶栏:**探索 / 连接 / 成长** 三组下拉,**EN · 主题 · 通知** 固定在右上
- `Ctrl+K` 空态只给三环捷径;次要页面要搜索才出现
- 工具箱默认精选 +「展开全部」,不再当全站站点地图
- **连接闭环:** 活动报名需登录(可取消);匹配 / 报名 / 讨论回复 / 赏金申请会写入通知中心;可选 ntfy 推送(`NTFY_ENABLED` + 设置里打开「推送」)
- **订阅:** 写入 PocketBase;配置 `LISTMONK_*` 后同步 Listmonk,未配置时文案如实提示「已登记」
## 技术栈
| 层级 | 技术 | 说明 |
|------|------|------|
| 前端 | Next.js 16 + React 19 | SSR/ISR、组件化 UI、Turbopack |
| 后端 | FastAPI | REST API、业务逻辑 |
| 数据库 | PocketBase | 数据持久化、管理后台 |
| 图表 | Chart.js (CDN) | 数据可视化 |
| 部署 | Vercel + Railway + Docker | 前后端分离部署 |
| 前端 | Next.js 16 + React 19 | **仅 UI**;App Router、standalone |
| 后端 | FastAPI | **全部业务逻辑**(无 Next.js 业务 API Route) |
| 数据 | PocketBase | 主存储;本地可 Mock 降级 |
| 对象存储 | S3 兼容(生产 SeaweedFS) | 媒体上传 |
| 部署 | Caddy + systemd | 原生进程;勿与同机 dylive 业务互相干扰 |
## 项目结构
```
nomadweb/
├── frontend/ # Next.js 前端应用
│ ├── src/app/ # 页面路由 (App Router)
│ ├── src/components/ # UI 组件 (30+)
│ └── src/lib/ # API 客户端、Auth、Toast
├── backend/ # FastAPI 后端
│ ├── app/routers/ # API 路由
│ ├── app/services/ # PocketBase、Auth 服务
│ └── app/data/ # Mock 降级数据
├── pocketbase/ # PocketBase 数据目录
├── scripts/ # 开发启动脚本 (dev.ps1 / dev.sh)
├── docker-compose.yml # 一键启动全部服务
├── index.html # 原始静态版(保留)
└── README.md
├── frontend/ # Next.js(页面与组件)
│ ├── src/app/ # 路由
│ ├── src/components/ # UI(含 RingNext 下一步条)
│ └── src/lib/ # api / auth / i18n / rings
├── backend/ # FastAPI
│ ├── app/routers/ # REST
│ ├── app/services/ # PocketBase、S3、城市深度等
│ └── app/data/ # Mock 降级(开发)
├── wemini/ # 微信小程序(Taro → weapp,可联网)
├── dymini/ # 抖音小程序(Taro → tt,可联网)
├── redmini/ # 小红书 miniTool(纯前端离线,禁止联网)
├── deploy/ # Caddy、systemd、env 模板
├── scripts/ # 本地开发与生产更新
└── pocketbase/ # PB 数据目录(本地可选)
```
### 小程序
| 目录 | 平台 | 联网 | 说明 |
|------|------|------|------|
| `wemini/` | 微信 | ✅ → `nomadweb.nomadro.com/api/v1` | Taro 原生 weapp(参考 Desktop/wemini:`ScrollView` + 手写 SCSS,**不用 web-view**);视觉对齐 H5 |
| `dymini/` | 抖音 | ✅ → 同上 | `npm run dev:tt` |
| `redmini/` | 小红书 | ❌ 纯离线 | H5 class DOM + `globals.css` 镜像;`python scripts/assemble.py && python scripts/pack.py` |
**微信 `wemini/`**
```bash
cd wemini
npm install --legacy-peer-deps
npm run dev:weapp
# 微信开发者工具打开本目录(miniprogramRoot=dist/),清缓存编译
```
| Tab | 内容 |
|-----|------|
| 首页 | Hero / 三条环 / 目的地 / 生活方式 / 签证 / 心声 / FAQ |
| 目的地 | 筛选 + 城市卡 → 详情页 |
| 活动 | Meetup 列表 + RSVP(需登录) |
| 我的 | 微信登录、收藏、等级 |
样式:`wemini/src/app.scss`、`wemini/src/styles/h5-theme.scss`(WXSS 安全,不灌整份 H5 CSS)。
Git 远程:`origin`(gitea)与 `nomadro`([git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git))。
## 快速开始
### 方式一:本地开发(推荐)
### 1. PocketBase(可选)
**1. 启动 PocketBase(可选)**
不启动则 API 使用 Mock。
```bash
# 下载: https://pocketbase.io/docs/
# 或使用 Docker:
docker run -d -p 8090:8090 -v ./pocketbase/pb_data:/pb_data ghcr.io/muchobien/pocketbase:latest
```
访问 http://localhost:8090/_/ 创建管理员(可选,不启动则自动使用 Mock 数据)。
**2. 启动 FastAPI 后端**
### 2. FastAPI
```bash
cd backend
pip install -r requirements.txt
cp .env.example .env # 按需修改
cp .env.example .env
uvicorn app.main:app --reload --port 8000
```
API 文档: http://localhost:8000/docs
文档:http://localhost:8000/docs
**3. 启动 Next.js 前端**
### 3. Next.js
```bash
cd frontend
@ -66,176 +112,129 @@ npm install --legacy-peer-deps
npm run dev
```
访问 http://localhost:3000
### 方式二:一键脚本(Windows)
访问:http://localhost:3000
```powershell
.\scripts\dev.ps1
.\scripts\dev.ps1 # Windows 一键
```
### 方式三:Docker Compose
## 核心功能一览
```bash
docker compose up -d
```
按环归类(深度页会互相引导,避免平铺):
| 服务 | 地址 |
### 探索
| 能力 | 路径 |
|------|------|
| 前端 | http://localhost:3000 |
| API | http://localhost:8000/docs |
| PocketBase | http://localhost:8090/_/ |
| 地图 / 目的地 | `/#map` `/#destinations` `/destinations/[slug]` |
| 智能匹配 | 首页 CTA / `M` |
| 下一站决策 | `/next-stop` |
| 城市对比 | `/compare` |
| 旅居计划 | `/plan`(时间轴、预算、签证提醒、清单、ICS) |
| 签证指南 | `/#visa` |
## 功能特性
### 首页核心板块
| 板块 | 说明 |
### 连接
| 能力 | 路径 |
|------|------|
| 🗺️ 世界地图 | 交互式 SVG 热力图,点击查看城市详情 |
| 🌍 目的地 | 筛选/搜索/排序,收藏,最多 4 城对比 |
| 🎯 智能匹配 | 4 步问卷,推荐最适合的旅居城市 |
| 🕐 时区看板 | 实时时钟 + 与国内团队工作时间重叠分析 |
| 🗓️ 行程规划 | 多城市规划,预算汇总,分享/导入链接 |
| 🧮 费用计算器 | 按目的地、住宿档次、月数估算预算 |
| 💱 多币种换算 | CNY/USD/EUR/THB 等 8 种货币互转 |
| 📊 就绪度测评 | 5 题评估你的数字游民准备程度 |
| 💻 生活方式 | 交互式「游民一天」时间轴 |
| 📋 签证指南 | 难度筛选 + 进度条可视化 |
| 📝 博客 | 文章列表 + 阅读进度条详情页 |
| ❓ FAQ | 手风琴问答 |
| 活动 RSVP / 直播间 | `/meetups` `/meetups/[id]/live` |
| 社区讨论 | `/community` |
| 游民匹配 | `/dating` |
| 私信 | `/chat` |
| 通知 | `/notifications` |
### 交互与体验
- ✨ Canvas 粒子背景 + 品牌加载动画
- 🌓 深色/浅色主题切换(localStorage 持久化)
- 🔍 全局搜索 `Ctrl+K`(目的地/博客/签证/FAQ)
- 🧭 右侧浮动章节导航
- 📊 顶部滚动进度条
- ⚖️ 可视化城市对比(评分圆环 + 条形图)
- 🔔 全局 Toast 通知
- ⌨️ 快捷键:`M` 智能匹配、`?` 帮助面板
- 📱 响应式布局,移动端适配
### 用户系统
- 🔐 登录 / 注册 / 一键演示账号
- ❤️ 收藏目的地(同步 API)
- 👤 用户中心:统计、收藏、行程展示
- 演示账号:`demo@nomadflow.io` / `demo123`
### 后端 API
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/v1/destinations` | GET | 目的地列表(region/search/sort) |
| `/api/v1/destinations/{slug}` | GET | 目的地详情 |
| `/api/v1/destinations/compare` | POST | 城市对比(2–4 城) |
| `/api/v1/calculator` | POST | 费用计算 |
| `/api/v1/search` | GET | 全局搜索 |
| `/api/v1/blog` | GET | 博客列表 |
| `/api/v1/blog/{slug}` | GET | 博客详情 |
| `/api/v1/auth/register` | POST | 注册 |
| `/api/v1/auth/login` | POST | 登录 |
| `/api/v1/auth/demo` | GET | 演示登录 |
| `/api/v1/auth/favorites` | GET/POST | 收藏管理 |
| `/api/v1/charts/*` | GET | 图表数据 |
| `/api/v1/subscribe` | POST | 邮件订阅 |
> PocketBase 不可用时,API 自动降级到 `backend/app/data/mock_data.py`。
## 页面路由
| 路径 | 说明 |
### 成长
| 能力 | 路径 |
|------|------|
| `/` | 首页(全部板块) |
| `/login` | 登录 / 注册 |
| `/profile` | 用户中心 |
| `/destinations/[slug]` | 目的地详情(城市画像、加入行程) |
| `/blog/[slug]` | 博客详情(阅读进度) |
| 游民学院 | `/digital` |
| 赏金任务 | `/gigs` |
| 工具箱 | `/tools`(实用小工具,非功能总览) |
| 电子书 | `/book` |
| 会员 | `/join` `/pricing` |
演示账号:`demo@nomadro.com` / `demo123`
## 环境变量
### Backend (`backend/.env`)
**Backend**
```env
POCKETBASE_URL=http://127.0.0.1:8090
POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io
POCKETBASE_ADMIN_PASSWORD=admin123456
POCKETBASE_ADMIN_EMAIL=...
POCKETBASE_ADMIN_PASSWORD=...
CORS_ORIGINS=http://localhost:3000
# S3(生产见服务器 deploy/nomadro-api.env)
```
### Frontend (`frontend/.env.local`)
**Frontend**
```env
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```
## 部署
生产构建:
### 生产环境(已上线)
```env
NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1
NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
```
| 项目 | 地址 |
|------|------|
## Git 与部署
仓库:
- [gitea.dsx2020.com/eric/nomadweb](https://gitea.dsx2020.com/eric/nomadweb)(`origin`)
- [git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git)(`nomadro`)
当前开发分支 **`dev2`**(亦保留 `dev1` / `main`)。
```bash
git checkout dev2
git pull origin dev2
# … 改代码 …
git push origin dev2
git push nomadro dev2
python scripts/deploy_update.py # 服务器 pull + 增量构建重启
```
| 生产进程 | 地址 |
|----------|------|
| 网站 | https://nomadweb.nomadro.com |
| API 健康检查 | https://nomadweb.nomadro.com/api/v1/health |
| API 文档 | https://nomadweb.nomadro.com/docs |
| Git 仓库 | https://gitea.dsx2020.com/eric/nomadweb |
| `nomadro-web` | `127.0.0.1:3055` |
| `nomadro-api` | `127.0.0.1:8055` |
| PocketBase | `127.0.0.1:8090` |
| S3 | `s3.nomadro.com` → SeaweedFS |
**服务器架构:**
- 服务器:`107.173.30.245`
- 反向代理:Caddy(自动 HTTPS)
- 前端容器:`127.0.0.1:3055` → Next.js
- 后端容器:`127.0.0.1:8055` → FastAPI
- 数据库:PocketBase(Docker 内部网络)
**注意:** 同机还跑有 [dylive.nomadro.com](https://dylive.nomadro.com/)(`/opt/douyinWEB`)。更新 nomadweb 时**不要**改动 / 限流 / 杀掉直播相关进程。
**更新部署:**
密钥仅放服务器 `deploy/nomadro-api.env`(模板见 `*.env.example`),勿提交。
```bash
# SSH 登录服务器后
cd /opt/nomadweb
git pull origin main
export DOMAIN=nomadweb.nomadro.com
export NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1
export CORS_ORIGINS=https://nomadweb.nomadro.com
bash scripts/deploy-production.sh
```
### 微信小程序(明文)
或使用 Docker Compose 生产配置:
| 项 | 值 |
|----|-----|
| AppID | `wx11f6afc44ea1532f` |
| AppSecret | `d1814f60f900cfdee1813ccc6e00bc2d` |
```bash
docker compose -f docker-compose.prod.yml up -d --build
cp deploy/caddy/nomadweb.caddy /etc/caddy/sites/nomadweb.caddy
systemctl reload caddy
```
### 本地 Docker Compose
```bash
docker compose up -d
```
### 前端 → Vercel
1. 导入 `frontend/` 目录
2. 环境变量:`NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1`
3. 自动构建部署
### 后端 → Railway
1. 连接 `backend/` 目录
2. 配置 `POCKETBASE_URL`、`CORS_ORIGINS`
3. 读取 `railway.toml` 自动部署
- 目录:`wemini/`(`project.config.json` 已写入 AppID)
- 服务端 env:`WECHAT_APPID` / `WECHAT_SECRET` / `WECHAT_ALLOW_DEVTOOLS`(见 `deploy/nomadro-api.env.example`)
- 登录:`wx.login` → `POST /api/v1/auth/wechat` `{ "code": "..." }`
- 公众平台 request 合法域名:`nomadweb.nomadro.com`
## 快捷键
| 按键 | 功能 |
|------|------|
| `Ctrl+K` | 全局搜索 |
| `M` | 智能目的地匹配 |
| `?` | 快捷键帮助 |
| `Ctrl+K` | 全局搜索(三环捷径) |
| `M` | 智能匹配 |
| `P` | 旅居计划 |
| `T` | 工具箱 |
| `?` | 帮助 |
| `Esc` | 关闭弹窗 |
## 相关
- 迭代说明:[/changelog](https://nomadweb.nomadro.com/changelog)
- 前端脚手架说明:`frontend/README.md`(以本文件为准)
## License
MIT

14
backend/.dockerignore Normal file
View File

@ -0,0 +1,14 @@
__pycache__
*.py[cod]
*.egg-info
.venv
venv
.env*
!.env.example
.git
.gitignore
*.md
.pytest_cache
.mypy_cache
.ruff_cache
tests

View File

@ -1,7 +1,10 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -1,13 +1,40 @@
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
pocketbase_url: str = "http://127.0.0.1:8090"
pocketbase_admin_email: str = "admin@nomadflow.io"
pocketbase_admin_password: str = "admin123456"
pocketbase_admin_email: str = "admin@nomadro.com"
pocketbase_admin_password: str = "Xiao4669805"
api_host: str = "0.0.0.0"
api_port: int = 8000
cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000"
site_base_url: str = "https://nomadweb.nomadro.com"
# SeaweedFS S3 (s3.nomadro.com → 127.0.0.1:8333)
s3_enabled: bool = True
s3_endpoint: str = "http://127.0.0.1:8333"
s3_public_url: str = "https://s3.nomadro.com"
s3_access_key: str = "nomadweb"
s3_secret_key: str = ""
s3_bucket: str = "nomadweb"
s3_region: str = "us-east-1"
s3_upload_prefix: str = "nomadweb"
# ntfy (127.0.0.1:2586 on production server)
ntfy_enabled: bool = False
ntfy_url: str = "http://127.0.0.1:2586"
# Optional Listmonk newsletter (leave empty = store only)
listmonk_url: str = ""
listmonk_user: str = ""
listmonk_password: str = ""
listmonk_list_id: int = 1
# Payment (from env on server)
dev_auto_pay: bool = False
payment_provider: str = "zpay"
mirotalk_url: str = "https://mirotalk.nomadro.com"
lounge_url: str = "https://lounge.nomadro.com"
@property
def cors_origin_list(self) -> list[str]:

View File

@ -0,0 +1,343 @@
"""City detail depth for destinations — NomadCNA city_details adapted to nomadro slugs."""
from __future__ import annotations
from typing import Any
from app.data.mock_data import DESTINATIONS
def _city_detail(dest: dict[str, Any], index: int) -> dict[str, Any]:
slug = dest["slug"]
name = dest["name"]
cost = int(dest.get("cost") or 5000)
speed = int(dest.get("speed") or 50)
temp = int(dest.get("temperature") or 22)
rating = float(dest.get("rating") or 8.5)
nomads_raw = str(dest.get("nomads_count") or "1000")
nomads = int("".join(c for c in nomads_raw if c.isdigit()) or "1000")
tags = dest.get("highlights") or []
tag_short = [t.replace("🏄 ", "").replace("🧘 ", "").replace("💰 ", "").replace("🌴 ", "")[:12] for t in tags][:4]
rent = max(1200, round(cost * 0.42))
food = max(800, round(cost * 0.28))
cowork = max(200, round(cost * 0.12))
transport = max(200, round(cost * 0.08))
leisure = max(300, cost - rent - food - cowork - transport)
hue = int(dest.get("hue") or 170)
return {
"citySlug": slug,
"guide": {
"summary": f"{name}适合想把远程工作和生活质量同时拉高的人。建议先用 2–4 周短住验证网络、社群和生活节奏,再决定是否长期停留。{dest.get('description', '')[:80]}",
"bestFor": tag_short or ["远程办公", "旅居试住"],
"workSetup": f"建议选择靠近核心生活区的住处,搭配 1 个固定共享办公点和 2 个备用咖啡馆。当前网速参考值为 {speed} Mbps。",
"arrivalChecklist": ["确认 4G/5G 覆盖", "预约第一周住宿", "加入本地社群", "收藏 2 个办公点", "报名一场线下活动"],
"linkedContent": ["digital-nomad-guide", "visa-playbook"],
},
"pros": {
"pros": [
f"月均生活成本约 ¥{cost:,},适合控制预算。",
f"社区活跃度参考 {nomads_raw},容易找到同频伙伴。",
f"综合评分 {rating}/10,亮点:{'、'.join(tag_short[:3]) or name}。",
],
"cons": [
"热门区域旺季住宿价格波动较大。",
"长期停留前仍需实测具体住处网络。",
"社交圈集中,需要主动参加活动融入。",
],
},
"reviews": {
"rating": rating,
"items": [
{"author": "小林", "role": "前端开发", "text": f"{name}最适合有稳定远程收入的人,白天专注工作,晚上参加小规模聚会。", "score": 5},
{"author": "Marco", "role": "UI 设计师", "text": "如果你需要高强度商务会面,建议先看交通和航班;如果是深度工作,这里很舒服。", "score": 4},
{"author": "阿静", "role": "内容创作者", "text": "生活素材很多,但要避免把旅行感当成生产力,最好保持固定作息。", "score": 4},
],
},
"cost": {
"monthlyTotal": cost,
"currency": "CNY",
"breakdown": [
{"label": "住宿", "amount": rent},
{"label": "餐饮", "amount": food},
{"label": "共享办公", "amount": cowork},
{"label": "交通", "amount": transport},
{"label": "社交/休闲", "amount": leisure},
],
"tip": "预算表与城市卡片成本字段关联,可按个人消费校准。",
},
"people": {
"nomadsNow": nomads,
"personas": [
{"name": "远程全职", "percent": 36},
{"name": "自由职业", "percent": 28},
{"name": "独立开发者", "percent": 18},
{"name": "内容创作者", "percent": 18},
],
"featuredProfiles": ["小林", "Marco", "阿静"],
},
"chat": {
"channels": [
{"name": f"{name}落地互助群", "members": min(999, nomads), "status": "open"},
{"name": f"{name}共享办公情报", "members": round(nomads * 0.42), "status": "open"},
{"name": "周末活动约局", "members": round(nomads * 0.28), "status": "members"},
],
"latestTopics": ["本周线下咖啡局", "哪里适合视频会议", "短租避坑清单"],
},
"photos": {
"items": [
f"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200&h={hue}",
"https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=1200",
"https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?w=1200",
]
},
"weather": {
"temperature": temp,
"humidity": 55 + (index * 3) % 30,
"bestMonths": ["3月", "4月", "10月", "11月"],
"note": f"当前气候估算适合中长期试住,体感温度需结合季节和住宿条件判断。",
},
"trends": {
"growth": [
{"month": "1月", "value": max(50, nomads - 160)},
{"month": "2月", "value": max(70, nomads - 120)},
{"month": "3月", "value": max(90, nomads - 80)},
{"month": "4月", "value": max(120, nomads - 40)},
{"month": "5月", "value": nomads},
],
"insight": f"{name}的社群热度正在上升,新增用户主要来自远程全职和自由职业人群。",
},
"demographics": {
"age": [
{"label": "22-27", "value": 24},
{"label": "28-34", "value": 46},
{"label": "35-44", "value": 22},
{"label": "45+", "value": 8},
],
"work": [
{"label": "技术/产品", "value": 34},
{"label": "内容/设计", "value": 26},
{"label": "运营/市场", "value": 18},
{"label": "创业/自由职业", "value": 22},
],
},
"relatedContent": ["digital-nomad-guide", "visa-playbook"],
}
CITY_DETAILS: list[dict[str, Any]] = [
_city_detail(d, i) for i, d in enumerate(DESTINATIONS)
]
CITY_DETAILS_BY_SLUG: dict[str, dict[str, Any]] = {
d["citySlug"]: d for d in CITY_DETAILS
}
# Extra nomad routes aligned with destination slugs
EXTRA_ROUTES: list[dict[str, Any]] = [
{
"slug": "sea-slow-trail",
"title": "东南亚慢旅三角",
"titleEn": "SEA Slow Trail",
"citySlugs": ["chiangmai", "bali"],
"durationDays": 90,
"budget": 18000,
"description": "清迈 → 巴厘岛,低成本 + 高速网络 + 成熟游民社区。",
"descriptionEn": "Chiang Mai to Bali for cost and community.",
"stops": ["清迈 45 天", "巴厘岛 45 天"],
"status": "published",
"emoji": "🌴",
},
{
"slug": "euro-culture-loop",
"title": "欧洲文化环线",
"titleEn": "Euro Culture Loop",
"citySlugs": ["lisbon", "barcelona"],
"durationDays": 60,
"budget": 35000,
"description": "里斯本 → 巴塞罗那,签证友好、文化丰富。",
"descriptionEn": "Lisbon to Barcelona for visas and culture.",
"stops": ["里斯本 30 天", "巴塞罗那 30 天"],
"status": "published",
"emoji": "🏰",
},
{
"slug": "latam-budget-run",
"title": "拉美性价比冲刺",
"titleEn": "LATAM Budget Run",
"citySlugs": ["mexico"],
"durationDays": 45,
"budget": 12000,
"description": "墨西哥城深度试住,贴近北美时区、生活成本可控。",
"descriptionEn": "Mexico City deep stay near North America timezones.",
"stops": ["墨西哥城 45 天"],
"status": "published",
"emoji": "🌮",
},
{
"slug": "asia-metro-sprint",
"title": "亚洲都市效率线",
"titleEn": "Asia Metro Sprint",
"citySlugs": ["tokyo", "chiangmai"],
"durationDays": 40,
"budget": 28000,
"description": "东京高效率冲刺 + 清迈成本回调,适合产品与工程团队。",
"descriptionEn": "Tokyo sprint then Chiang Mai cooldown.",
"stops": ["东京 14 天", "清迈 26 天"],
"status": "published",
"emoji": "🚄",
},
{
"slug": "tax-base-loop",
"title": "税务友好试住线",
"titleEn": "Tax Base Loop",
"citySlugs": ["tbilisi", "dubai"],
"durationDays": 75,
"budget": 22000,
"description": "第比利斯试住 + 迪拜枢纽月,适合评估税务与中转策略。",
"descriptionEn": "Tbilisi base trial plus Dubai hub month.",
"stops": ["第比利斯 45 天", "迪拜 30 天"],
"status": "published",
"emoji": "🧾",
},
{
"slug": "latam-spring-trail",
"title": "拉美春城双城",
"titleEn": "LATAM Spring Trail",
"citySlugs": ["medellin", "mexico"],
"durationDays": 60,
"budget": 15000,
"description": "麦德林 → 墨西哥城,北美时区远程友好。",
"descriptionEn": "Medellín to Mexico City for NA timezones.",
"stops": ["麦德林 30 天", "墨西哥城 30 天"],
"status": "published",
"emoji": "🌺",
},
{
"slug": "create-slow-china",
"title": "国内慢创作线",
"titleEn": "China Slow Create",
"citySlugs": ["dali"],
"durationDays": 45,
"budget": 9000,
"description": "大理深度创作,适合内容创作者与独立开发者。",
"descriptionEn": "Dali deep create stay.",
"stops": ["大理 45 天"],
"status": "published",
"emoji": "📷",
},
{
"slug": "euro-oss-sprint",
"title": "欧洲开源冲刺线",
"titleEn": "Euro OSS Sprint",
"citySlugs": ["berlin", "lisbon"],
"durationDays": 50,
"budget": 32000,
"description": "柏林技术社群 + 里斯本阳光回调。",
"descriptionEn": "Berlin tech then Lisbon sun.",
"stops": ["柏林 25 天", "里斯本 25 天"],
"status": "published",
"emoji": "🖥️",
},
{
"slug": "east-asia-net",
"title": "东亚网速双城",
"titleEn": "East Asia Net Duo",
"citySlugs": ["seoul", "tokyo"],
"durationDays": 35,
"budget": 30000,
"description": "首尔与东京高强度协作月,适合产品迭代。",
"descriptionEn": "Seoul and Tokyo sprint months.",
"stops": ["首尔 18 天", "东京 17 天"],
"status": "published",
"emoji": "📶",
},
]
CONTENT_ITEMS: list[dict[str, Any]] = [
{
"slug": "digital-nomad-guide",
"type": "guide",
"title": "数字游民落地手册",
"titleEn": "Digital Nomad Landing Guide",
"subtitle": "签证、网络、住宿、社群一次看懂",
"description": "覆盖东南亚与欧洲热门城市的 30 天落地清单。",
"coverImage": "",
"mediaUrl": "",
"targetUrl": "/digital",
"ctaLabel": "打开手册",
"citySlugs": ["bali", "chiangmai", "lisbon"],
"sortOrder": 1,
"status": "published",
"authorName": "nomadro",
},
{
"slug": "visa-playbook",
"type": "guide",
"title": "签证选址速查",
"titleEn": "Visa Playbook",
"subtitle": "D7 / Nomad Visa / LTR 对照",
"description": "按收入门槛与审批周期对比主流签证路径。",
"targetUrl": "/#visa",
"ctaLabel": "看签证",
"citySlugs": ["lisbon", "barcelona", "tokyo"],
"sortOrder": 2,
"status": "published",
"authorName": "nomadro",
},
{
"slug": "bali-cowork-week",
"type": "video",
"title": "巴厘岛一周联合办公实录",
"subtitle": "Canggu 节奏与咖啡馆踩点",
"description": "适合第一次去巴厘岛远程工作的人。",
"targetUrl": "/videos",
"ctaLabel": "看视频",
"citySlugs": ["bali"],
"sortOrder": 3,
"status": "published",
"authorName": "nomadro",
},
{
"slug": "medellin-trial",
"type": "guide",
"title": "麦德林试住手册",
"titleEn": "Medellín Trial Guide",
"subtitle": "安全区、西语与北美时区",
"description": "30 天试住清单与预算模板。",
"targetUrl": "/destinations/medellin",
"ctaLabel": "看城市",
"citySlugs": ["medellin"],
"sortOrder": 4,
"status": "published",
"authorName": "Sofia",
},
{
"slug": "tbilisi-tax-note",
"type": "guide",
"title": "第比利斯税务笔记",
"titleEn": "Tbilisi Tax Notes",
"subtitle": "远程签证与开户提醒",
"description": "非正式法律意见,帮助你先做试住决策。",
"targetUrl": "/destinations/tbilisi",
"ctaLabel": "打开笔记",
"citySlugs": ["tbilisi"],
"sortOrder": 5,
"status": "published",
"authorName": "Devon",
},
{
"slug": "first-month-video",
"type": "video",
"title": "旅居第一月稳定输出",
"subtitle": "SIM、短租、社群五步法",
"description": "新手最容易踩坑的第一周怎么过。",
"targetUrl": "/videos/first-month",
"ctaLabel": "看视频",
"citySlugs": ["chiangmai", "lisbon", "bali"],
"sortOrder": 6,
"status": "published",
"authorName": "nomadro",
},
]

View File

@ -0,0 +1,783 @@
"""Community & events mock data — adapted from NomadCNA business logic."""
MEETUPS = [
{
"id": "online-roundtable",
"title": "远程工作节奏圆桌",
"city": "线上",
"destination_slug": "",
"emoji": "🎙️",
"date": "2026-09-12",
"time": "20:00",
"venue": "nomadro 线上活动厅",
"description": "聊聊异步协作、时区管理和旅居城市选择。登录后可 RSVP,活动前会收到提醒。",
"mode": "online",
"access_level": "public",
"mirotalkRoom": "nomadro-online-roundtable",
"loungeChannel": "#nomadro-online",
"meetingUrl": "",
"rsvp_count": 38,
"max_attendees": 80,
"organizer": "nomadro 社区",
"tags": ["远程", "协作", "新手友好"],
"is_upcoming": True,
},
{
"id": "chiangmai-cowork",
"title": "清迈联合办公下午茶",
"city": "清迈",
"destination_slug": "chiangmai",
"emoji": "☕",
"date": "2026-09-18",
"time": "15:00",
"venue": "Nimman 区 Co-working",
"description": "一起办公、分享签证经验和住宿踩坑。线下为主,也开放线上旁听链接。",
"mode": "hybrid",
"access_level": "public",
"mirotalkRoom": "nomadro-chiangmai-cowork",
"loungeChannel": "#chiangmai-cowork",
"rsvp_count": 14,
"max_attendees": 24,
"organizer": "清迈游民小组",
"tags": ["线下", "社交", "东南亚"],
"is_upcoming": True,
},
{
"id": "lisbon-sunset",
"title": "里斯本日落漫步",
"city": "里斯本",
"destination_slug": "lisbon",
"emoji": "🌅",
"date": "2026-09-22",
"time": "18:30",
"venue": "Miradouro 观景点集合",
"description": "工作一周后,和同城游民一起看日落、交换欧洲签证情报。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 9,
"max_attendees": 16,
"organizer": "Lisbon Nomads",
"tags": ["欧洲", "户外", "社交"],
"is_upcoming": True,
},
{
"id": "bali-surf-morning",
"title": "巴厘岛晨间冲浪 + 办公",
"city": "巴厘岛",
"destination_slug": "bali",
"emoji": "🏄",
"date": "2026-10-05",
"time": "07:00",
"venue": "Canggu Beach",
"description": "早起冲浪,10 点回咖啡馆集中办公。适合想体验 Work-Life 平衡的游民。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 11,
"max_attendees": 20,
"organizer": "Bali Remote Crew",
"tags": ["运动", "生活方式"],
"is_upcoming": True,
},
{
"id": "tokyo-tax-talk",
"title": "日本税务与居留线上分享",
"city": "线上",
"destination_slug": "tokyo",
"emoji": "🧾",
"date": "2026-10-12",
"time": "19:30",
"venue": "线上直播",
"description": "特邀长期旅居日本的游民分享税务申报、保险和银行开户经验(非法律建议)。",
"mode": "online",
"access_level": "members",
"rsvp_count": 52,
"max_attendees": 120,
"organizer": "nomadro 社区",
"tags": ["税务", "日本", "干货"],
"is_upcoming": True,
},
{
"id": "berlin-opensource",
"title": "柏林开源与 DevOps 夜谈",
"city": "柏林",
"destination_slug": "berlin",
"emoji": "🖥️",
"date": "2026-09-28",
"time": "19:00",
"venue": "Kreuzberg Co-working",
"description": "分享自托管、CI 与远程团队协作实践,会后可继续社交。",
"mode": "hybrid",
"access_level": "public",
"mirotalkRoom": "nomadro-berlin-oss",
"rsvp_count": 22,
"max_attendees": 40,
"organizer": "Berlin Remote",
"tags": ["开源", "技术", "欧洲"],
"is_upcoming": True,
},
{
"id": "dali-create-walk",
"title": "大理洱海创作散步",
"city": "大理",
"destination_slug": "dali",
"emoji": "📷",
"date": "2026-10-02",
"time": "16:00",
"venue": "才村码头集合",
"description": "边走边拍,分享内容选题与变现路径,适合创作者与慢旅居者。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 8,
"max_attendees": 18,
"organizer": "大理创作者局",
"tags": ["内容", "户外", "国内"],
"is_upcoming": True,
},
{
"id": "medellin-spanish",
"title": "麦德林西语咖啡局",
"city": "麦德林",
"destination_slug": "medellin",
"emoji": "☕",
"date": "2026-10-08",
"time": "10:30",
"venue": "Laureles 咖啡馆",
"description": "初级西语练习 + 安全区与租房情报交换,新手友好。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 16,
"max_attendees": 20,
"organizer": "Medellín Nomads",
"tags": ["语言", "拉美", "社交"],
"is_upcoming": True,
},
{
"id": "seoul-night-office",
"title": "首尔深夜联合办公",
"city": "首尔",
"destination_slug": "seoul",
"emoji": "🌃",
"date": "2026-10-15",
"time": "21:00",
"venue": "江南共享办公",
"description": "适合东亚时区冲刺交付的游民,自带电脑即可。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 12,
"max_attendees": 25,
"organizer": "Seoul Workation",
"tags": ["办公", "韩国", "效率"],
"is_upcoming": True,
},
{
"id": "dubai-hub-brunch",
"title": "迪拜枢纽早午餐",
"city": "迪拜",
"destination_slug": "dubai",
"emoji": "🥂",
"date": "2026-10-18",
"time": "11:00",
"venue": "Downtown 咖啡厅",
"description": "中转游民见面:签证、航班与下一站城市选择。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 10,
"max_attendees": 16,
"organizer": "Dubai Hub Crew",
"tags": ["中东", "社交", "跳板"],
"is_upcoming": True,
},
{
"id": "tbilisi-tax-cafe",
"title": "第比利斯税务咖啡聊",
"city": "第比利斯",
"destination_slug": "tbilisi",
"emoji": "🍷",
"date": "2026-10-22",
"time": "15:00",
"venue": "Vake 区咖啡馆",
"description": "交流远程签证与本地公司实务(非正式法律意见),会后可徒步旧城。",
"mode": "offline",
"access_level": "members",
"rsvp_count": 19,
"max_attendees": 22,
"organizer": "Tbilisi Base",
"tags": ["税务", "欧洲", "干货"],
"is_upcoming": True,
},
{
"id": "mexico-food-walk",
"title": "墨西哥城街头美食漫步",
"city": "墨西哥城",
"destination_slug": "mexico",
"emoji": "🌮",
"date": "2026-10-26",
"time": "17:30",
"venue": "Roma Norte 集合",
"description": "边吃边聊北美时区接单与安全出行经验。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 13,
"max_attendees": 18,
"organizer": "CDMX Nomads",
"tags": ["美食", "拉美", "社交"],
"is_upcoming": True,
},
]
DISCUSSIONS = [
{
"id": "visa-sea-2026",
"title": "2026 东南亚长期停留签证怎么选?",
"excerpt": "泰国 DTV、马来西亚 DE Rantau、印尼第二家园……大家最近实际办下来体验如何?",
"author": "小林",
"author_emoji": "🧳",
"category": "签证",
"reply_count": 23,
"like_count": 47,
"is_pinned": True,
"created_at": "2026-08-20",
"tags": ["签证", "东南亚"],
},
{
"id": "async-remote",
"title": "异步团队怎么写日报才不烦人?",
"excerpt": "我们团队跨 5 个时区,想收集团队节奏模板和工具推荐。",
"author": "Marco",
"author_emoji": "💻",
"category": "远程工作",
"reply_count": 15,
"like_count": 31,
"is_pinned": False,
"created_at": "2026-08-22",
"tags": ["远程", "协作"],
},
{
"id": "coliving-tips",
"title": "第一次租 Coliving 必问房东的 10 个问题",
"excerpt": "押金、网速、清洁、访客政策……欢迎补充你的踩坑经历。",
"author": "阿静",
"author_emoji": "🏡",
"category": "住宿",
"reply_count": 19,
"like_count": 56,
"is_pinned": False,
"created_at": "2026-08-25",
"tags": ["住宿", "经验"],
},
{
"id": "health-insurance",
"title": "数字游民国际医疗保险对比",
"excerpt": "SafetyWing、Genki、本地险……按年龄段和目的地聊聊性价比。",
"author": "Yuki",
"author_emoji": "🏥",
"category": "安全",
"reply_count": 11,
"like_count": 28,
"is_pinned": False,
"created_at": "2026-08-27",
"tags": ["保险", "安全"],
},
{
"id": "meetup-feedback",
"title": "你希望 nomadro 线下活动开在哪些城市?",
"excerpt": "我们在规划 Q4 活动路线,投票 + 留言告诉我们你的城市!",
"author": "nomadro",
"author_emoji": "🌍",
"category": "社区",
"reply_count": 34,
"like_count": 72,
"is_pinned": True,
"created_at": "2026-08-28",
"tags": ["活动", "投票"],
},
{
"id": "medellin-safety",
"title": "麦德林哪些街区适合第一次长住?",
"excerpt": "Laureles / Poblado / Envigado 的网速、噪音和夜间出行体验求分享。",
"author": "Sofia",
"author_emoji": "🌺",
"category": "住宿",
"reply_count": 17,
"like_count": 41,
"created_at": "2026-08-30",
"tags": ["麦德林", "安全"],
},
{
"id": "tbilisi-bank",
"title": "第比利斯开户与收美元的实操经验",
"excerpt": "需要本地号码吗?Wise 能不能直接用?求最新流程。",
"author": "Devon",
"author_emoji": "🍷",
"category": "财务",
"reply_count": 14,
"like_count": 36,
"created_at": "2026-09-01",
"tags": ["格鲁吉亚", "银行"],
},
{
"id": "berlin-winter",
"title": "柏林冬天怎么保持产出和社交?",
"excerpt": "日照短、天气冷,有没有室内运动 + meetup 推荐。",
"author": "Ken",
"author_emoji": "🎨",
"category": "生活方式",
"reply_count": 9,
"like_count": 22,
"created_at": "2026-09-02",
"tags": ["柏林", "健康"],
},
{
"id": "dali-wifi",
"title": "大理合租找房:网速坑位合集",
"excerpt": "哪些区域光纤稳定?有没有房东靠谱名单。",
"author": "Lina",
"author_emoji": "📷",
"category": "住宿",
"reply_count": 21,
"like_count": 48,
"created_at": "2026-09-03",
"tags": ["大理", "网速"],
},
{
"id": "dubai-summer",
"title": "迪拜夏天还能正常远程办公吗?",
"excerpt": "空调联合办公、作息调整和预算增加幅度求真实数据。",
"author": "Omar",
"author_emoji": "🏙️",
"category": "生活方式",
"reply_count": 8,
"like_count": 19,
"created_at": "2026-09-04",
"tags": ["迪拜", "气候"],
},
{
"id": "seoul-visa",
"title": "韩国 Workation 签证材料清单核对",
"excerpt": "收入证明、保险、行程……有人最近过签吗?",
"author": "Yuki",
"author_emoji": "🇰🇷",
"category": "签证",
"reply_count": 12,
"like_count": 33,
"created_at": "2026-09-05",
"tags": ["韩国", "签证"],
},
{
"id": "cowork-month-pass",
"title": "各城市联合办公月票性价比对比",
"excerpt": "清迈 / 里斯本 / 柏林 / 麦德林,欢迎贴价目与座位体验。",
"author": "Alex",
"author_emoji": "🚀",
"category": "远程工作",
"reply_count": 26,
"like_count": 61,
"created_at": "2026-09-06",
"tags": ["办公", "成本"],
},
]
GIGS = [
{
"id": "logo-design",
"title": "为游民社区设计 Logo",
"description": "需要扁平风格,含地球+代码元素,交付 SVG。",
"budget": "¥800",
"deadline": "2026-09-15",
"tags": ["设计", "远程"],
"poster": "nomadro",
"poster_email": "demo@nomadro.com",
"status": "open",
"category": "设计",
"location": "远程",
},
{
"id": "meetup-host",
"title": "深圳线下活动主持人",
"description": "协助组织 20 人规模的游民交流会,有经验优先。",
"budget": "¥500/场",
"deadline": "2026-09-20",
"tags": ["活动", "深圳"],
"poster": "深圳湾区主理人",
"poster_email": "demo@nomadro.com",
"status": "open",
"category": "活动",
"location": "深圳",
},
{
"id": "content-translate",
"title": "签证指南英文润色",
"description": "将 3 篇中文签证攻略翻译润色为英文博客。",
"budget": "¥1200",
"deadline": "2026-10-01",
"tags": ["翻译", "内容"],
"poster": "nomadro",
"poster_email": "demo@nomadro.com",
"status": "open",
"category": "内容",
"location": "远程",
},
{
"id": "landing-page",
"title": "城市落地页前端实现",
"description": "Next.js + Tailwind,按 Figma 还原清迈/里斯本两套落地页。",
"budget": "¥3500",
"deadline": "2026-10-10",
"tags": ["前端", "Next.js"],
"poster": "Marco",
"poster_email": "marco@nomadro.com",
"status": "open",
"category": "开发",
"location": "远程",
},
{
"id": "video-edit",
"title": "游民访谈短视频剪辑",
"description": "4 条 60–90 秒竖版视频,含字幕与封面。",
"budget": "¥1500",
"deadline": "2026-10-12",
"tags": ["视频", "剪辑"],
"poster": "Lina",
"poster_email": "lina@nomadro.com",
"status": "open",
"category": "内容",
"location": "远程",
},
{
"id": "seo-audit",
"title": "目的地内容 SEO 审计",
"description": "审计 10 篇城市指南关键词与内链结构,输出改进清单。",
"budget": "¥900",
"deadline": "2026-10-08",
"tags": ["SEO", "内容"],
"poster": "nomadro",
"poster_email": "demo@nomadro.com",
"status": "open",
"category": "运营",
"location": "远程",
},
{
"id": "photo-pack",
"title": "大理旅居照片素材包",
"description": "需 30 张可商用风格照片(咖啡馆、洱海、合租场景)。",
"budget": "¥600",
"deadline": "2026-10-05",
"tags": ["摄影", "大理"],
"poster": "Lina",
"poster_email": "lina@nomadro.com",
"status": "open",
"category": "设计",
"location": "大理",
},
{
"id": "tax-checklist",
"title": "格鲁吉亚税务清单校对",
"description": "把英文政策摘要整理成中文 checklist(非法律意见)。",
"budget": "¥700",
"deadline": "2026-10-18",
"tags": ["税务", "文档"],
"poster": "Devon",
"poster_email": "devon@nomadro.com",
"status": "open",
"category": "内容",
"location": "远程",
},
{
"id": "meetup-photo",
"title": "麦德林活动跟拍",
"description": "一场线下咖啡局跟拍 + 当天出 15 张精修。",
"budget": "$80",
"deadline": "2026-10-08",
"tags": ["摄影", "活动"],
"poster": "Sofia",
"poster_email": "sofia@nomadro.com",
"status": "open",
"category": "活动",
"location": "麦德林",
},
{
"id": "chatbot-faq",
"title": "签证 FAQ 聊天机器人语料",
"description": "整理 50 条问答并标注意图,供客服机器人训练。",
"budget": "¥1100",
"deadline": "2026-10-20",
"tags": ["AI", "内容"],
"poster": "Alex",
"poster_email": "alex@nomadro.com",
"status": "open",
"category": "开发",
"location": "远程",
},
{
"id": "ux-test",
"title": "下一站推荐工具可用性测试",
"description": "招募 5 名游民完成任务并录制反馈(远程)。",
"budget": "¥80/人",
"deadline": "2026-10-15",
"tags": ["UX", "研究"],
"poster": "Sara",
"poster_email": "sara@nomadro.com",
"status": "open",
"category": "产品",
"location": "远程",
},
{
"id": "community-mod",
"title": "周末社区值班版主",
"description": "处理讨论区举报与欢迎新人,需中英双语。",
"budget": "¥300/周末",
"deadline": "2026-10-30",
"tags": ["社区", "兼职"],
"poster": "nomadro",
"poster_email": "demo@nomadro.com",
"status": "open",
"category": "运营",
"location": "远程",
},
]
DISCUSSION_REPLIES = {
"visa-sea-2026": [
{
"id": "r1",
"author": "阿Ken",
"author_emoji": "🇹🇭",
"content": "DTV 刚办下来,材料比想象中简单,关键是银行流水和远程工作证明。",
"created_at": "2026-08-21",
"like_count": 12,
},
{
"id": "r2",
"author": "Sara",
"author_emoji": "🇲🇾",
"content": "DE Rantau 审批大概 3 周,适合想待吉隆坡的。",
"created_at": "2026-08-21",
"like_count": 8,
},
{
"id": "r3",
"author": "Yuki",
"author_emoji": "🇮🇩",
"content": "巴厘岛短期用 B211A 足够,长期再看第二家园成本是否划算。",
"created_at": "2026-08-22",
"like_count": 6,
},
],
"async-remote": [
{
"id": "r1",
"author": "Devon",
"author_emoji": "🕐",
"content": "我们用 Loom 录屏 + 简短文字摘要,比长日报友好很多。",
"created_at": "2026-08-23",
"like_count": 9,
},
{
"id": "r2",
"author": "Alex",
"author_emoji": "🚀",
"content": "模板三要素:昨天完成 / 今天计划 / 卡点,别写散文。",
"created_at": "2026-08-23",
"like_count": 11,
},
],
"coliving-tips": [
{
"id": "r1",
"author": "小林",
"author_emoji": "🧳",
"content": "一定要问清押金退还条件和网速实测截图,别只看宣传。",
"created_at": "2026-08-26",
"like_count": 14,
},
],
"medellin-safety": [
{
"id": "r1",
"author": "Alex",
"author_emoji": "🌮",
"content": "第一次建议 Laureles,生活成本与便利平衡;夜间少拿手机逛街。",
"created_at": "2026-08-31",
"like_count": 10,
},
],
"tbilisi-bank": [
{
"id": "r1",
"author": "Omar",
"author_emoji": "🏙️",
"content": "先办本地 SIM 再去银行,材料准备护照+地址证明更顺利。",
"created_at": "2026-09-01",
"like_count": 7,
},
],
"dali-wifi": [
{
"id": "r1",
"author": "阿静",
"author_emoji": "🏡",
"content": "才村/喜洲合租差别大,入住前用手机热点对照测速最靠谱。",
"created_at": "2026-09-03",
"like_count": 9,
},
],
"cowork-month-pass": [
{
"id": "r1",
"author": "Marco",
"author_emoji": "💻",
"content": "里斯本月票大约 €150–250;清迈很多空间 ¥600–1000 就够用。",
"created_at": "2026-09-06",
"like_count": 15,
},
],
"health-insurance": [
{
"id": "r1",
"author": "Sofia",
"author_emoji": "🏥",
"content": "30 岁以下 SafetyWing 够用;有慢性病建议加本地门诊险。",
"created_at": "2026-08-28",
"like_count": 10,
},
{
"id": "r2",
"author": "Ken",
"author_emoji": "🇯🇵",
"content": "在日本长期停留我叠了本地国民健康保险,报销更稳。",
"created_at": "2026-08-28",
"like_count": 7,
},
],
"meetup-feedback": [
{
"id": "r1",
"author": "Lina",
"author_emoji": "📷",
"content": "大理 + 清迈希望能固定每月一场创作局。",
"created_at": "2026-08-29",
"like_count": 18,
},
{
"id": "r2",
"author": "Omar",
"author_emoji": "🏙️",
"content": "迪拜适合枢纽月见面,建议放周末早午餐。",
"created_at": "2026-08-29",
"like_count": 9,
},
],
"berlin-winter": [
{
"id": "r1",
"author": "Devon",
"author_emoji": "🖥️",
"content": "冬天把联合办公当社交主场,再加一周一次室内攀岩/跑步局。",
"created_at": "2026-09-02",
"like_count": 11,
},
],
"dubai-summer": [
{
"id": "r1",
"author": "Nina",
"author_emoji": "🍷",
"content": "夏天基本只在空调空间办公,预算里把交通和水电预留高一点。",
"created_at": "2026-09-04",
"like_count": 6,
},
],
"seoul-visa": [
{
"id": "r1",
"author": "Jin",
"author_emoji": "🇰🇷",
"content": "最近材料重点是收入证明和保险;审批大约 3–6 周,建议提前办。",
"created_at": "2026-09-05",
"like_count": 8,
},
],
}
NOMAD_ROUTES = [
{
"id": "sea-slow",
"slug": "sea-slow-trail",
"title": "东南亚慢旅三角",
"duration_days": 90,
"budget": 18000,
"description": "清迈 → 巴厘岛 → 吉隆坡,低成本 + 高速网络 + 成熟游民社区。",
"stops": ["chiangmai", "bali"],
"emoji": "🌴",
},
{
"id": "euro-culture",
"slug": "euro-culture-loop",
"title": "欧洲文化环线",
"duration_days": 60,
"budget": 35000,
"description": "里斯本 → 巴塞罗那,签证友好、文化丰富。",
"stops": ["lisbon", "barcelona"],
"emoji": "🏰",
},
{
"id": "latam-budget",
"slug": "latam-budget-run",
"title": "拉美性价比冲刺",
"duration_days": 45,
"budget": 12000,
"description": "墨西哥城深度试住,贴近北美时区、生活成本低。",
"stops": ["mexico"],
"emoji": "🌮",
},
{
"id": "asia-metro",
"slug": "asia-metro-sprint",
"title": "亚洲都市效率线",
"duration_days": 40,
"budget": 28000,
"description": "东京高效率冲刺 + 清迈成本回调。",
"stops": ["tokyo", "chiangmai"],
"emoji": "🚄",
},
{
"id": "tax-base-loop",
"slug": "tax-base-loop",
"title": "税务友好试住线",
"duration_days": 75,
"budget": 22000,
"description": "第比利斯试住 + 迪拜枢纽月,适合评估税务与中转策略。",
"stops": ["tbilisi", "dubai"],
"emoji": "🧾",
},
{
"id": "latam-spring",
"slug": "latam-spring-trail",
"title": "拉美春城双城",
"duration_days": 60,
"budget": 15000,
"description": "麦德林 → 墨西哥城,北美时区远程友好。",
"stops": ["medellin", "mexico"],
"emoji": "🌺",
},
{
"id": "create-slow",
"slug": "create-slow-china",
"title": "国内慢创作线",
"duration_days": 45,
"budget": 9000,
"description": "大理深度创作 + 短途补给,适合内容创作者。",
"stops": ["dali"],
"emoji": "📷",
},
{
"id": "euro-oss",
"slug": "euro-oss-sprint",
"title": "欧洲开源冲刺线",
"duration_days": 50,
"budget": 32000,
"description": "柏林技术社群 + 里斯本阳光回调。",
"stops": ["berlin", "lisbon"],
"emoji": "🖥️",
},
]

View File

@ -0,0 +1,210 @@
"""Digital nomad academy content — nomadro /digital sub-site."""
SITE_ID = "nomadro-digital"
COURSE_MODULES = [
{
"title": "启程:远程工作基础",
"lessons": [
{"title": "什么是数字游民", "duration": "8 分钟", "free": True},
{"title": "异步协作入门", "duration": "12 分钟", "free": True},
{"title": "时区与会议管理", "duration": "15 分钟", "free": False},
],
},
{
"title": "签证与税务",
"lessons": [
{"title": "东南亚长期签证概览", "duration": "18 分钟", "free": False},
{"title": "183 天规则与税务居民", "duration": "14 分钟", "free": False},
],
},
{
"title": "Homelab 与连接",
"lessons": [
{"title": "把家装进一个机柜", "duration": "20 分钟", "free": False},
{"title": "无论在哪都能连回家", "duration": "16 分钟", "free": False},
],
},
]
LESSONS = {
"0-0": {
"title": "什么是数字游民",
"moduleIndex": 0,
"lessonIndex": 0,
"duration": "8 分钟",
"free": True,
"content": "数字游民是一种工作方式:产出不绑定在某个地理坐标上。你可以在任何有网络的地方完成交付。",
},
"0-1": {
"title": "异步协作入门",
"moduleIndex": 0,
"lessonIndex": 1,
"duration": "12 分钟",
"free": True,
"content": "异步沟通不是「随时在线」,而是「明确预期」:写清楚截止时间、交付物和决策人。",
},
"0-2": {
"title": "时区与会议管理",
"moduleIndex": 0,
"lessonIndex": 2,
"duration": "15 分钟",
"free": False,
"content": "用 overlap 窗口安排会议,非重叠时段留给深度工作。工具箱里的「会议黄金时段」可以帮你算。",
},
"1-0": {
"title": "东南亚长期签证概览",
"moduleIndex": 1,
"lessonIndex": 0,
"duration": "18 分钟",
"free": False,
"content": "泰国 DTV、马来西亚 DE Rantau、印尼第二家园……各国有不同的门槛与材料清单。",
},
"1-1": {
"title": "183 天规则与税务居民",
"moduleIndex": 1,
"lessonIndex": 1,
"duration": "14 分钟",
"free": False,
"content": "多数国家用 183 天判定税务居民。用 nomadro 税居天数工具追踪停留。",
},
"2-0": {
"title": "把家装进一个机柜",
"moduleIndex": 2,
"lessonIndex": 0,
"duration": "20 分钟",
"free": False,
"content": "Homelab 让你在旅途中拥有可控的 NAS、VPN 和开发环境。",
},
"2-1": {
"title": "无论在哪都能连回家",
"moduleIndex": 2,
"lessonIndex": 1,
"duration": "16 分钟",
"free": False,
"content": "WireGuard + 动态 DNS,把家里的服务安全暴露给在外的你。",
},
}
JOBS = [
{
"id": "j1",
"title": "Senior Frontend Engineer",
"company": "Remote First Co",
"location": "全球远程",
"type": "全职",
"salary": "$80k–120k",
"tags": ["React", "TypeScript", "远程"],
"url": "/gigs",
},
{
"id": "j2",
"title": "DevOps / SRE",
"company": "Nomad Labs",
"location": "欧洲时区",
"type": "合同",
"salary": "€60–90/h",
"tags": ["K8s", "AWS", "异步"],
"url": "/gigs",
},
{
"id": "j3",
"title": "内容运营(中文)",
"company": "nomadro",
"location": "东南亚友好",
"type": "兼职",
"salary": "面议",
"tags": ["社区", "写作", "游民"],
"url": "/gigs",
},
{
"id": "j4",
"title": "Product Designer",
"company": "Atlantic Remote",
"location": "欧盟时区 ±3h",
"type": "全职",
"salary": "€55k–75k",
"tags": ["Figma", "B2B", "远程"],
"url": "/gigs",
},
{
"id": "j5",
"title": "Growth Marketer",
"company": "Hub Ventures",
"location": "中东/欧洲友好",
"type": "合同",
"salary": "$4k–6k/月",
"tags": ["SEO", "内容", "增长"],
"url": "/gigs",
},
{
"id": "j6",
"title": "Backend Engineer (Python)",
"company": "Latam Cloud",
"location": "北美时区",
"type": "全职",
"salary": "$90k–130k",
"tags": ["Python", "FastAPI", "Postgres"],
"url": "/gigs",
},
{
"id": "j7",
"title": "Community Manager",
"company": "nomadro",
"location": "全球远程",
"type": "兼职",
"salary": "¥8k–12k/月",
"tags": ["社区", "活动", "中英"],
"url": "/gigs",
},
{
"id": "j8",
"title": "Mobile Engineer (iOS/Android)",
"company": "Seoul Soft",
"location": "东亚时区",
"type": "全职",
"salary": "$70k–100k",
"tags": ["Flutter", "移动端"],
"url": "/gigs",
},
{
"id": "j9",
"title": "Technical Writer",
"company": "Docs Anywhere",
"location": "异步优先",
"type": "合同",
"salary": "$40–70/h",
"tags": ["文档", "开发者关系"],
"url": "/gigs",
},
{
"id": "j10",
"title": "Data Analyst",
"company": "Wander Metrics",
"location": "全球远程",
"type": "全职",
"salary": "$65k–95k",
"tags": ["SQL", "看板", "增长"],
"url": "/gigs",
},
{
"id": "j11",
"title": "Customer Success(签证顾问支持)",
"company": "nomadro",
"location": "中国时区友好",
"type": "兼职",
"salary": "¥6k–10k/月",
"tags": ["客服", "签证", "中文"],
"url": "/gigs",
},
{
"id": "j12",
"title": "Full-stack Engineer",
"company": "Indie Nomad Tools",
"location": "全球远程",
"type": "全职",
"salary": "$85k–115k",
"tags": ["Next.js", "Python", "独立产品"],
"url": "/gigs",
},
]

View File

@ -1,4 +1,4 @@
"""Fallback data when PocketBase is unavailable."""
"""Fallback data when PocketBase is unavailable."""
DESTINATIONS = [
{
@ -43,6 +43,48 @@ DESTINATIONS = [
"nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"],
"map_x": 820, "map_y": 210,
},
{
"id": "7", "slug": "berlin", "name": "柏林", "country": "德国", "emoji": "🎨",
"tag": "欧洲 · 创意之都", "description": "开放的创业与开源氛围,租金相对西欧友好,夜生活与艺术场景极具吸引力。",
"region": "europe", "cost": 9800, "speed": 140, "temperature": 12, "rating": 8.9, "hue": 250,
"nomads_count": "7,200+", "highlights": ["🎨 艺术与街头文化", "💻 创业生态活跃", "🚲 骑行友好", "🍺 社交场景丰富"],
"map_x": 500, "map_y": 160,
},
{
"id": "8", "slug": "dali", "name": "大理", "country": "中国", "emoji": "🏔️",
"tag": "国内 · 慢生活", "description": "苍山洱海之间的内容创作者聚集地,生活节奏慢、社交松弛,适合深度创作与试住。",
"region": "asia", "cost": 4200, "speed": 80, "temperature": 18, "rating": 8.7, "hue": 190,
"nomads_count": "3,600+", "highlights": ["📷 内容创作友好", "🏞️ 自然风光", "🍵 咖啡馆密集", "🏡 合租成本低"],
"map_x": 730, "map_y": 220,
},
{
"id": "9", "slug": "seoul", "name": "首尔", "country": "韩国", "emoji": "🏙️",
"tag": "亚洲 · 潮流都市", "description": "高速网络与深夜城市活力并存,适合对接东亚市场与产品迭代。",
"region": "asia", "cost": 11000, "speed": 220, "temperature": 14, "rating": 8.5, "hue": 330,
"nomads_count": "3,200+", "highlights": ["📶 全球顶尖网速", "🍜 美食与便利店文化", "🛍️ 潮流消费", "🚇 地铁覆盖极强"],
"map_x": 800, "map_y": 200,
},
{
"id": "10", "slug": "medellin", "name": "麦德林", "country": "哥伦比亚", "emoji": "🌺",
"tag": "拉美 · 春城", "description": "四季如春、生活成本友好,英语游民社区成熟,适合北美时区远程工作。",
"region": "latam", "cost": 5200, "speed": 90, "temperature": 23, "rating": 9.0, "hue": 140,
"nomads_count": "6,400+", "highlights": ["🌤️ 四季如春", "💰 高性价比", "🕐 北美时区", "🤝 社区活动密集"],
"map_x": 260, "map_y": 300,
},
{
"id": "11", "slug": "dubai", "name": "迪拜", "country": "阿联酋", "emoji": "🏙️",
"tag": "中东 · 枢纽都市", "description": "免税与签证便利吸引远程高收入人群,国际航班枢纽,适合中东/欧亚跳板。",
"region": "asia", "cost": 14000, "speed": 180, "temperature": 32, "rating": 8.4, "hue": 40,
"nomads_count": "4,800+", "highlights": ["🧾 税务友好", "✈️ 航线枢纽", "🏗️ 现代基础设施", "🌐 多元国际社区"],
"map_x": 600, "map_y": 240,
},
{
"id": "12", "slug": "tbilisi", "name": "第比利斯", "country": "格鲁吉亚", "emoji": "🍷",
"tag": "欧亚 · 税务友好", "description": "1% 小企业税与远程友好签证,旧城氛围浓厚,东欧游民新基地。",
"region": "europe", "cost": 4800, "speed": 70, "temperature": 16, "rating": 8.8, "hue": 15,
"nomads_count": "5,100+", "highlights": ["🧾 1% 小企业税", "🏰 旧城与温泉", "🍷 葡萄酒文化", "💰 低生活成本"],
"map_x": 560, "map_y": 190,
},
]
VISAS = [
@ -58,6 +100,18 @@ VISAS = [
"duration": "1-4年", "income_req": "$2,500/月", "approval_time": "2-4 周", "extra": "🌮 北美时区友好", "difficulty": 30, "difficulty_label": "简单"},
{"id": "6", "country": "爱沙尼亚", "flag": "🇪🇪", "name": "爱沙尼亚 DNV", "badge": "🚀 先锋", "badge_type": "pioneer",
"duration": "1年", "income_req": "€3,504/月", "approval_time": "2-4 周", "extra": "💻 全球首个数字游民签证", "difficulty": 40, "difficulty_label": "中等"},
{"id": "7", "country": "格鲁吉亚", "flag": "🇬🇪", "name": "格鲁吉亚 Remotely from Georgia", "badge": "⭐ 推荐", "badge_type": "easy",
"duration": "1年", "income_req": "$2,000/月", "approval_time": "约 10 天", "extra": "🧾 可搭配小企业 1% 税", "difficulty": 28, "difficulty_label": "简单"},
{"id": "8", "country": "阿联酋", "flag": "🇦🇪", "name": "迪拜 Virtual Working Programme", "badge": "🔥 热门", "badge_type": "hot",
"duration": "1年", "income_req": "$3,500/月", "approval_time": "2-4 周", "extra": "✈️ 中东航线枢纽", "difficulty": 42, "difficulty_label": "中等"},
{"id": "9", "country": "哥伦比亚", "flag": "🇨🇴", "name": "哥伦比亚 V Visa (Digital Nomad)", "badge": "💰 低成本", "badge_type": "budget",
"duration": "最长 2 年", "income_req": "约 3× 最低工资", "approval_time": "2-6 周", "extra": "🌺 适合麦德林春城", "difficulty": 32, "difficulty_label": "简单"},
{"id": "10", "country": "韩国", "flag": "🇰🇷", "name": "韩国 Workation Visa (C-3-8)", "badge": "🆕 新政策", "badge_type": "new",
"duration": "最长 2 年", "income_req": "约 $65,000/年", "approval_time": "1-2 个月", "extra": "🏙️ 对接首尔与济州", "difficulty": 55, "difficulty_label": "中等"},
{"id": "11", "country": "德国", "flag": "🇩🇪", "name": "德国 Freelance / Self-employment", "badge": "🚀 先锋", "badge_type": "pioneer",
"duration": "1-3年", "income_req": "需商业计划与客户证明", "approval_time": "2-4 个月", "extra": "🎨 适合柏林自由职业", "difficulty": 65, "difficulty_label": "较难"},
{"id": "12", "country": "泰国", "flag": "🇹🇭", "name": "泰国 DTV 签证", "badge": "⭐ 推荐", "badge_type": "easy",
"duration": "最长 5 年(多次入境)", "income_req": "约 50 万泰铢存款证明", "approval_time": "2-6 周", "extra": "🤝 远程工作友好", "difficulty": 38, "difficulty_label": "中等"},
]
FAQS = [
@ -79,6 +133,11 @@ TESTIMONIALS = [
{"id": "1", "avatar": "👩‍💻", "content": "在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。", "author": "小林", "role": "前端开发 · 清迈 🇹🇭", "rating": 5},
{"id": "2", "avatar": "👨‍🎨", "content": "里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。", "author": "Marco", "role": "UI 设计师 · 里斯本 🇵🇹", "rating": 5},
{"id": "3", "avatar": "🧑‍💼", "content": "带着家人做数字游民听起来疯狂,但在巴厘岛乌布,孩子们上国际学校,我和妻子远程工作,这是我们做过最正确的决定。", "author": "张家", "role": "产品经理 · 巴厘岛 🇮🇩", "rating": 5},
{"id": "4", "avatar": "👩‍🎨", "content": "麦德林四季如春,英语社区很活跃。我用北美时区接单,下午还能去公园运动,效率反而更高。", "author": "Sofia", "role": "自由职业设计 · 麦德林 🇨🇴", "rating": 5},
{"id": "5", "avatar": "👨‍💻", "content": "第比利斯生活成本低,还能开本地公司走 1% 税。旧城咖啡馆写代码,周末去卡兹别吉徒步,节奏很舒服。", "author": "Devon", "role": "DevOps · 第比利斯 🇬🇪", "rating": 5},
{"id": "6", "avatar": "📷", "content": "大理适合慢创作。合租便宜、风景治愈,但要主动找同频伙伴,别只待在民宿里。", "author": "Lina", "role": "内容创作者 · 大理 🇨🇳", "rating": 4},
{"id": "7", "avatar": "🧑‍🚀", "content": "柏林创业氛围强,开源 meetup 特别多。冬天冷、德语门槛真实存在,但找到圈子后很值得。", "author": "Ken", "role": "数据分析 · 柏林 🇩🇪", "rating": 4},
{"id": "8", "avatar": "🏙️", "content": "迪拜当跳板很香:航班方便、签证路径清晰。夏天太热,建议把深度工作时间放在室内联合办公。", "author": "Omar", "role": "增长顾问 · 迪拜 🇦🇪", "rating": 4},
]
TOOLS = [
@ -91,9 +150,16 @@ TOOLS = [
]
BLOG_POSTS = [
{"id": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "NomadFlow", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]},
{"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "NomadFlow", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]},
{"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "NomadFlow", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]},
{"id": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "nomadro", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]},
{"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "nomadro", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]},
{"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "nomadro", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]},
{"id": "4", "slug": "medellin-spring-city", "title": "麦德林春城试住 30 天笔记", "excerpt": "时区、安全区、联合办公与西语社区初体验。", "emoji": "🌺", "author": "Sofia", "published_at": "2026-03-05", "read_time": 9, "tags": ["麦德林", "拉美", "试住"]},
{"id": "5", "slug": "tbilisi-tax-base", "title": "第比利斯当税务基地靠谱吗?", "excerpt": "远程签证、小企业税与旧城生活成本实测。", "emoji": "🍷", "author": "Devon", "published_at": "2026-03-18", "read_time": 11, "tags": ["格鲁吉亚", "税务", "欧洲"]},
{"id": "6", "slug": "dali-slow-create", "title": "大理慢创作:合租、网速与社群", "excerpt": "国内游民城市怎么住得久又不散漫。", "emoji": "🏔️", "author": "Lina", "published_at": "2026-04-02", "read_time": 7, "tags": ["大理", "内容", "国内"]},
{"id": "7", "slug": "berlin-startup-nomad", "title": "柏林自由职业与开源社群指南", "excerpt": "签证路径、共享办公与每周活动清单。", "emoji": "🎨", "author": "Ken", "published_at": "2026-04-20", "read_time": 10, "tags": ["柏林", "创业", "开源"]},
{"id": "8", "slug": "dubai-hub-month", "title": "迪拜一个月跳板旅居怎么排期", "excerpt": "签证、炎热季节、航班中转与办公点选择。", "emoji": "🏙️", "author": "Omar", "published_at": "2026-05-01", "read_time": 8, "tags": ["迪拜", "中东", "枢纽"]},
{"id": "9", "slug": "seoul-workation", "title": "首尔 Workation:网速、便利与成本", "excerpt": "C-3-8 签证与江南区办公踩点。", "emoji": "🏙️", "author": "Yuki", "published_at": "2026-05-16", "read_time": 9, "tags": ["首尔", "韩国", "网速"]},
{"id": "10", "slug": "first-month-checklist", "title": "旅居第一月清单:从落地到稳定输出", "excerpt": "SIM、短租、联合办公、社群与预算五步走。", "emoji": "✅", "author": "nomadro", "published_at": "2026-06-01", "read_time": 6, "tags": ["清单", "新手", "落地"]},
]
BLOG_CONTENT: dict[str, str] = {
@ -217,6 +283,102 @@ A: 满足居住要求后可以申请永居或入籍。""",
3. 🏦 使用 Wise 等工具便于跨境汇款记录
4. 👨‍💼 收入超过一定金额建议聘请税务顾问
5. 📱 推荐工具:Xero(记账)、TaxScouts(报税)""",
"medellin-spring-city": """## 为什么麦德林?
四季如春、生活成本可控,且贴近北美时区,适合接美加客户。Laureles / El Poblado 是游民常见落脚区。
## 30 天试住建议
1. 前 7 天住短租,实测网速与噪音
2. 办本地 SIM,备份热点
3. 参加 2 场线下 meetup,验证社区匹配度
4. 确定联合办公月票再签长租
## 预算参考(月)
- 住宿:¥2,000–3,500
- 餐饮:¥1,200–1,800
- 交通:¥300–500
- 办公:¥400–800""",
"tbilisi-tax-base": """## 远程签证 + 小企业税
格鲁吉亚对远程工作者友好,许多人会评估本地公司与 1% 税路径(需自行核实最新政策)。
## 生活感受
旧城与 Vake 区咖啡馆多,适合深度工作;冬季偏冷,夏天舒服。
## 注意
- 银行开户与本地手机号流程可能变动
- 税务方案务必咨询持证顾问
- 把「试住 30 天」和「税务落地」分开决策""",
"dali-slow-create": """## 适合谁
内容创作者、独立开发者、想放慢节奏但仍保持产出的人。
## 实操建议
- 合租优先选稳定 WiFi 与独立工作桌
- 用固定作息对抗「度假感」
- 主动加入本地创作者局,避免社交孤岛
## 成本
月生活费常可控制在 ¥3,500–5,000,视合租与出行频率而定。""",
"berlin-startup-nomad": """## 柏林为什么吸引游民
开源、设计、创业活动密度高,共享办公选择多。
## 落地要点
- 提前规划居留/自由职业路径
- 冬天日照短,准备补光与室内运动
- 德语不是必须,但能明显提升生活便利
## 每周节奏
2 天联合办公 + 1 场 meetup + 周末短途,比纯咖啡馆更稳。""",
"dubai-hub-month": """## 适合当跳板
国际航班密集,签证路径相对清晰,适合中东/欧亚中转月。
## 炎热季节
夏季高温,深度工作尽量安排在空调联合办公;户外活动放早晚。
## 预算
整体偏高,建议按「枢纽月」而非「低成本旅居」来规划。""",
"seoul-workation": """## 网速与便利
首尔网速与城市便利度极强,适合需要高频协作的产品/工程角色。
## 成本与节奏
住宿与餐饮成本接近一线城市;用便利店与共享办公控制变量。
## 签证
关注 Workation / 相关短期远程路径的最新材料要求。""",
"first-month-checklist": """## 落地五步
1. **连接**:落地办 SIM,测公寓与办公点网速
2. **住处**:先短租 7–14 天,再决定长租
3. **办公**:固定 1 个主点 + 2 个备份咖啡馆
4. **社群**:报名 1 场 meetup,加 1 个本地群
5. **预算**:用第一周真实账单校准月预算
## 输出稳定
每天保护 4 小时深度工作,旅行探索放在下午后段。""",
}
TICKER_MESSAGES = [
@ -226,4 +388,10 @@ TICKER_MESSAGES = [
"🏝️ 巴厘岛 Co-working 今日 89% 满座",
"📶 清迈平均网速 95Mbps",
"🌅 东京远程工作者满意度 9.1",
"🌺 Sofia 在麦德林办完本地 SIM",
"🍷 Devon 把下一站设为第比利斯",
"🎨 Ken 报名柏林开源 Meetup",
"🏙️ Omar 从迪拜中转飞往里斯本",
"📷 Lina 在大理发布本周创作日志",
"🇰🇷 Yuki 实测首尔联合办公 220Mbps",
]

View File

@ -0,0 +1,312 @@
"""Platform content: services, videos, help, member map seeds."""
SERVICES = [
{
"id": "visa-consult",
"title": "签证方案咨询",
"description": "一对一梳理 D7/D8/DTV 等长期签证路径,含材料清单与时间线。",
"emoji": "📋",
"price": "¥299 起",
"category": "签证",
"provider": "nomadro 认证顾问",
},
{
"id": "tax-review",
"title": "税务居民评估",
"description": "跨境收入、183 天规则与双重征税协定初步评估(非正式法律意见)。",
"emoji": "🧾",
"price": "¥399 起",
"category": "税务",
"provider": "合作会计师",
},
{
"id": "relocation",
"title": "落地安家陪跑",
"description": "选房区、办 SIM、开户、联合办公 —— 7 天线上陪跑。",
"emoji": "🏠",
"price": "¥899 起",
"category": "落地",
"provider": "城市志愿者",
},
{
"id": "remote-job",
"title": "远程简历优化",
"description": "面向欧美远程岗位的英文简历与 LinkedIn 优化。",
"emoji": "💼",
"price": "¥199 起",
"category": "职业",
"provider": "HR 志愿者",
},
{
"id": "coliving-match",
"title": "合租匹配咨询",
"description": "按城市、预算与作息匹配合租对象,附沟通话术模板。",
"emoji": "🏡",
"price": "¥149 起",
"category": "住宿",
"provider": "社区主理人",
},
{
"id": "insurance-pick",
"title": "国际保险选型",
"description": "按年龄、目的地与既往史对比 SafetyWing / Genki / 本地险。",
"emoji": "🏥",
"price": "¥99 起",
"category": "安全",
"provider": "保险顾问",
},
{
"id": "content-pack",
"title": "城市内容素材包",
"description": "可商用照片 + 短视频 B-roll,覆盖咖啡馆与联合办公场景。",
"emoji": "📷",
"price": "¥259 起",
"category": "内容",
"provider": "创作者网络",
},
{
"id": "plan-coach",
"title": "90 天旅居计划辅导",
"description": "一起排城市顺序、预算与签证节点,输出可执行计划表。",
"emoji": "🗓️",
"price": "¥499 起",
"category": "规划",
"provider": "nomadro 教练",
},
{
"id": "bank-setup",
"title": "跨境账户开户辅导",
"description": "Wise / Revolut / 本地银行材料清单与常见拒因规避。",
"emoji": "💳",
"price": "¥179 起",
"category": "财务",
"provider": "财务志愿者",
},
{
"id": "meetup-host-coach",
"title": "线下活动主办陪跑",
"description": "从选题、场地到 RSVP 转化,帮你办成第一场同城局。",
"emoji": "🎉",
"price": "¥399 起",
"category": "社区",
"provider": "活动主理人",
},
]
VIDEOS = [
{
"id": "chiangmai-2025",
"slug": "chiangmai-2025",
"title": "清迈:月入 8000 的游民一天",
"excerpt": "联合办公、夜市、签证续签与社区活动实录。",
"emoji": "🌴",
"duration": "18:42",
"guest": "小林",
"city": "清迈",
"published_at": "2026-03-12",
"tags": ["泰国", "成本", "社区"],
"video_url": "",
},
{
"id": "lisbon-d7",
"slug": "lisbon-d7",
"title": "里斯本 D7 签证上岸访谈",
"excerpt": "从材料准备到 NIF、银行账户的完整时间线。",
"emoji": "🇵🇹",
"duration": "24:10",
"guest": "Marco",
"city": "里斯本",
"published_at": "2026-02-08",
"tags": ["葡萄牙", "签证"],
"video_url": "",
},
{
"id": "bali-cowork",
"slug": "bali-cowork",
"title": "巴厘岛联合办公巡礼",
"excerpt": "对比 Canggu / Ubud 五家热门空间网速与月票。",
"emoji": "🏝️",
"duration": "15:33",
"guest": "Yuki",
"city": "巴厘岛",
"published_at": "2026-01-20",
"tags": ["印尼", "办公"],
"video_url": "",
},
{
"id": "medellin-30d",
"slug": "medellin-30d",
"title": "麦德林 30 天试住 vlog",
"excerpt": "安全区、西语学习与北美时区接单节奏。",
"emoji": "🌺",
"duration": "16:20",
"guest": "Sofia",
"city": "麦德林",
"published_at": "2026-03-28",
"tags": ["哥伦比亚", "试住"],
"video_url": "",
},
{
"id": "tbilisi-tax",
"slug": "tbilisi-tax",
"title": "第比利斯税务基地访谈",
"excerpt": "远程签证、开户与旧城生活成本。",
"emoji": "🍷",
"duration": "21:05",
"guest": "Devon",
"city": "第比利斯",
"published_at": "2026-04-05",
"tags": ["格鲁吉亚", "税务"],
"video_url": "",
},
{
"id": "berlin-oss",
"slug": "berlin-oss",
"title": "柏林开源 Meetup 一日",
"excerpt": "创业办公空间与技术社交怎么玩。",
"emoji": "🎨",
"duration": "14:48",
"guest": "Ken",
"city": "柏林",
"published_at": "2026-04-22",
"tags": ["德国", "开源"],
"video_url": "",
},
{
"id": "dali-create",
"slug": "dali-create",
"title": "大理慢创作一周",
"excerpt": "合租、网速与内容产出作息。",
"emoji": "📷",
"duration": "12:55",
"guest": "Lina",
"city": "大理",
"published_at": "2026-05-03",
"tags": ["大理", "内容"],
"video_url": "",
},
{
"id": "dubai-hub",
"slug": "dubai-hub",
"title": "迪拜枢纽月怎么排",
"excerpt": "签证、炎热季节与航班中转策略。",
"emoji": "🏙️",
"duration": "13:40",
"guest": "Omar",
"city": "迪拜",
"published_at": "2026-05-18",
"tags": ["迪拜", "中东"],
"video_url": "",
},
{
"id": "seoul-net",
"slug": "seoul-net",
"title": "首尔网速与深夜办公",
"excerpt": "江南联合办公实测与 Workation 签证提示。",
"emoji": "🇰🇷",
"duration": "11:22",
"guest": "Yuki",
"city": "首尔",
"published_at": "2026-06-02",
"tags": ["韩国", "网速"],
"video_url": "",
},
{
"id": "mexico-food",
"slug": "mexico-food",
"title": "墨西哥城美食与办公平衡",
"excerpt": "Roma Norte 咖啡馆踩点与时区协作。",
"emoji": "🌮",
"duration": "15:10",
"guest": "Alex",
"city": "墨西哥城",
"published_at": "2026-06-15",
"tags": ["墨西哥", "美食"],
"video_url": "",
},
{
"id": "first-month",
"slug": "first-month",
"title": "旅居第一月:从慌乱到稳定",
"excerpt": "SIM、短租、社群与预算五步法。",
"emoji": "✅",
"duration": "10:05",
"guest": "nomadro",
"city": "线上",
"published_at": "2026-06-28",
"tags": ["新手", "清单"],
"video_url": "",
},
{
"id": "barcelona-visa",
"slug": "barcelona-visa",
"title": "巴塞罗那 Nomad Visa 体验",
"excerpt": "材料、审批周期与地中海生活成本。",
"emoji": "🏖️",
"duration": "19:30",
"guest": "Sara",
"city": "巴塞罗那",
"published_at": "2026-07-08",
"tags": ["西班牙", "签证"],
"video_url": "",
},
]
MEMBER_MAP = [
{"name": "小林", "city": "清迈", "lat": 18.79, "lng": 98.98, "emoji": "🧑‍💻"},
{"name": "Marco", "city": "里斯本", "lat": 38.72, "lng": -9.14, "emoji": "🇵🇹"},
{"name": "Yuki", "city": "巴厘岛", "lat": -8.34, "lng": 115.09, "emoji": "🏝️"},
{"name": "Alex", "city": "墨西哥城", "lat": 19.43, "lng": -99.13, "emoji": "🌮"},
{"name": "Sofia", "city": "巴塞罗那", "lat": 41.39, "lng": 2.17, "emoji": "☀️"},
{"name": "Ken", "city": "东京", "lat": 35.68, "lng": 139.69, "emoji": "🗼"},
{"name": "Lina", "city": "大理", "lat": 25.60, "lng": 100.27, "emoji": "🏔️"},
{"name": "Omar", "city": "迪拜", "lat": 25.20, "lng": 55.27, "emoji": "🏙️"},
{"name": "Devon", "city": "柏林", "lat": 52.52, "lng": 13.40, "emoji": "🖥️"},
{"name": "Sara", "city": "麦德林", "lat": 6.25, "lng": -75.56, "emoji": "🌺"},
{"name": "Nina", "city": "第比利斯", "lat": 41.72, "lng": 44.79, "emoji": "🍷"},
{"name": "Jin", "city": "首尔", "lat": 37.57, "lng": 126.98, "emoji": "🇰🇷"},
{"name": "Priya", "city": "里斯本", "lat": 38.74, "lng": -9.15, "emoji": "💻"},
{"name": "Tom", "city": "清迈", "lat": 18.80, "lng": 98.97, "emoji": "☕"},
{"name": "Mia", "city": "巴厘岛", "lat": -8.65, "lng": 115.14, "emoji": "🧘"},
{"name": "Leo", "city": "墨西哥城", "lat": 19.42, "lng": -99.16, "emoji": "🚀"},
]
CITY_RANKING = [
{"slug": "chiangmai", "name": "清迈", "score": 92, "nomads": "12k+"},
{"slug": "lisbon", "name": "里斯本", "score": 89, "nomads": "8k+"},
{"slug": "bali", "name": "巴厘岛", "score": 87, "nomads": "15k+"},
{"slug": "mexico", "name": "墨西哥城", "score": 85, "nomads": "6k+"},
{"slug": "barcelona", "name": "巴塞罗那", "score": 84, "nomads": "7k+"},
{"slug": "medellin", "name": "麦德林", "score": 88, "nomads": "6k+"},
{"slug": "tbilisi", "name": "第比利斯", "score": 86, "nomads": "5k+"},
{"slug": "berlin", "name": "柏林", "score": 83, "nomads": "7k+"},
{"slug": "dali", "name": "大理", "score": 82, "nomads": "4k+"},
{"slug": "seoul", "name": "首尔", "score": 81, "nomads": "3k+"},
{"slug": "tokyo", "name": "东京", "score": 80, "nomads": "4k+"},
{"slug": "dubai", "name": "迪拜", "score": 79, "nomads": "5k+"},
]
DAILY_TIPS = {
"1": {"title": "第一天:确定税务居民身份", "body": "出发前了解母国与目标国的 183 天规则,避免双重征税意外。"},
"2": {"title": "第二天:准备多币种账户", "body": "Wise / Revolut 等跨境账户可大幅降低取现与换汇成本。"},
"3": {"title": "第三天:测试远程办公网速", "body": "落地先住 3 天短租,实测联合办公与公寓 WiFi 再签长租。"},
"4": {"title": "第四天:加入同城社群", "body": "报名一场 meetup,比独自逛咖啡馆更快建立支持网络。"},
"5": {"title": "第五天:备份通讯方案", "body": "本地 SIM + eSIM 热点双备份,开会前先压测上行带宽。"},
"6": {"title": "第六天:校准月预算", "body": "用第一周真实账单更新计划表,别只信网上平均值。"},
"7": {"title": "第七天:固定作息", "body": "保护每日 4 小时深度工作,探索放在下午后段。"},
}
WEATHER_CITIES = {
"chiangmai": {"lat": 18.79, "lng": 98.98, "name": "清迈"},
"lisbon": {"lat": 38.72, "lng": -9.14, "name": "里斯本"},
"bali": {"lat": -8.34, "lng": 115.09, "name": "巴厘岛"},
"mexico": {"lat": 19.43, "lng": -99.13, "name": "墨西哥城"},
"barcelona": {"lat": 41.39, "lng": 2.17, "name": "巴塞罗那"},
"tokyo": {"lat": 35.68, "lng": 139.69, "name": "东京"},
"berlin": {"lat": 52.52, "lng": 13.40, "name": "柏林"},
"dali": {"lat": 25.60, "lng": 100.27, "name": "大理"},
"seoul": {"lat": 37.57, "lng": 126.98, "name": "首尔"},
"medellin": {"lat": 6.25, "lng": -75.56, "name": "麦德林"},
"dubai": {"lat": 25.20, "lng": 55.27, "name": "迪拜"},
"tbilisi": {"lat": 41.72, "lng": 44.79, "name": "第比利斯"},
}

View File

@ -0,0 +1,102 @@
"""Nomad matching candidate profiles — adapted from NomadCNA business logic."""
MATCH_INTENTS = ("friends", "dating", "partner", "roommate", "cofounder", "explore")
CANDIDATE_PROFILES = [
{
"id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiangmai",
"gender": "女", "single": "是", "bio": "远程前端,喜欢咖啡和徒步。",
"photo": "🧳", "tags": ["远程", "咖啡", "徒步"],
"lookingFor": ["friends", "dating", "explore"],
},
{
"id": "p2", "userId": "u2", "name": "Marco", "location": "里斯本", "citySlug": "lisbon",
"gender": "男", "single": "是", "bio": "全栈工程师,欧洲旅居第三年。",
"photo": "💻", "tags": ["全栈", "欧洲", "冲浪"],
"lookingFor": ["friends", "cofounder", "explore"],
},
{
"id": "p3", "userId": "u3", "name": "Yuki", "location": "巴厘岛", "citySlug": "bali",
"gender": "女", "single": "否", "bio": "设计师,寻找合租室友。",
"photo": "🎨", "tags": ["设计", "瑜伽", "合租"],
"lookingFor": ["roommate", "friends"],
},
{
"id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico",
"gender": "男", "single": "是", "bio": "独立开发者,拉美时区常驻。",
"photo": "🚀", "tags": ["独立开发", "拉美"],
"lookingFor": ["cofounder", "friends", "dating"],
},
{
"id": "p5", "userId": "u5", "name": "Sara", "location": "巴塞罗那", "citySlug": "barcelona",
"gender": "女", "single": "是", "bio": "产品运营,热爱美食与市集。",
"photo": "☕", "tags": ["产品", "美食"],
"lookingFor": ["dating", "partner", "friends"],
},
{
"id": "p6", "userId": "u6", "name": "Ken", "location": "东京", "citySlug": "tokyo",
"gender": "男", "single": "是", "bio": "数据分析师,东亚签证达人。",
"photo": "📊", "tags": ["数据", "签证"],
"lookingFor": ["friends", "explore"],
},
{
"id": "p7", "userId": "u7", "name": "Lina", "location": "大理", "citySlug": "dali",
"gender": "女", "single": "是", "bio": "内容创作者,慢生活倡导者。",
"photo": "📷", "tags": ["内容", "慢生活"],
"lookingFor": ["friends", "dating", "roommate"],
},
{
"id": "p8", "userId": "u8", "name": "Devon", "location": "柏林", "citySlug": "berlin",
"gender": "非二元", "single": "是", "bio": "DevOps,开源社区活跃成员。",
"photo": "🖥️", "tags": ["DevOps", "开源"],
"lookingFor": ["cofounder", "friends"],
},
{
"id": "p9", "userId": "u9", "name": "Sofia", "location": "麦德林", "citySlug": "medellin",
"gender": "女", "single": "是", "bio": "自由设计师,北美时区接单,爱西语咖啡局。",
"photo": "🌺", "tags": ["设计", "西语", "拉美"],
"lookingFor": ["friends", "dating", "explore"],
},
{
"id": "p10", "userId": "u10", "name": "Omar", "location": "迪拜", "citySlug": "dubai",
"gender": "男", "single": "是", "bio": "增长顾问,常把迪拜当欧亚跳板。",
"photo": "🏙️", "tags": ["增长", "中东", "航班"],
"lookingFor": ["friends", "partner", "explore"],
},
{
"id": "p11", "userId": "u11", "name": "Nina", "location": "第比利斯", "citySlug": "tbilisi",
"gender": "女", "single": "是", "bio": "独立顾问,研究税务友好基地与旧城生活。",
"photo": "🍷", "tags": ["税务", "顾问", "欧洲"],
"lookingFor": ["friends", "cofounder"],
},
{
"id": "p12", "userId": "u12", "name": "Jin", "location": "首尔", "citySlug": "seoul",
"gender": "男", "single": "是", "bio": "移动端工程师,沉迷深夜联合办公。",
"photo": "🇰🇷", "tags": ["移动端", "效率", "网速"],
"lookingFor": ["friends", "roommate", "explore"],
},
{
"id": "p13", "userId": "u13", "name": "Priya", "location": "里斯本", "citySlug": "lisbon",
"gender": "女", "single": "是", "bio": "产品设计师,喜欢海边徒步与博物馆。",
"photo": "🌊", "tags": ["产品设计", "徒步"],
"lookingFor": ["dating", "friends"],
},
{
"id": "p14", "userId": "u14", "name": "Tom", "location": "清迈", "citySlug": "chiangmai",
"gender": "男", "single": "是", "bio": "后端工程师,咖啡店常客,周末骑摩托。",
"photo": "☕", "tags": ["后端", "咖啡", "摩托"],
"lookingFor": ["friends", "cofounder", "explore"],
},
{
"id": "p15", "userId": "u15", "name": "Mia", "location": "巴厘岛", "citySlug": "bali",
"gender": "女", "single": "是", "bio": "瑜伽老师兼内容创作者,寻找同频合租。",
"photo": "🧘", "tags": ["瑜伽", "内容", "合租"],
"lookingFor": ["roommate", "friends", "partner"],
},
{
"id": "p16", "userId": "u16", "name": "Leo", "location": "墨西哥城", "citySlug": "mexico",
"gender": "男", "single": "是", "bio": "创业者,做拉美远程协作工具。",
"photo": "🌮", "tags": ["创业", "拉美", "SaaS"],
"lookingFor": ["cofounder", "friends", "dating"],
},
]

View File

@ -1,11 +1,11 @@
from fastapi import FastAPI
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import api, auth
from app.routers import api, auth, community, ebook, media, meetup_auth, payment, platform, social
app = FastAPI(
title="NomadFlow API",
title="nomadro API",
description="数字游民旅居平台后端 API",
version="1.0.0",
)
@ -20,12 +20,19 @@ app.add_middleware(
app.include_router(api.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1")
app.include_router(meetup_auth.router, prefix="/api/v1")
app.include_router(social.router, prefix="/api/v1")
app.include_router(community.router, prefix="/api/v1")
app.include_router(payment.router, prefix="/api/v1")
app.include_router(ebook.router, prefix="/api/v1")
app.include_router(platform.router, prefix="/api/v1")
app.include_router(media.router, prefix="/api/v1")
@app.get("/")
async def root():
return {
"name": "NomadFlow API",
"name": "nomadro API",
"docs": "/docs",
"health": "/api/v1/health",
}

View File

@ -1,12 +1,25 @@
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Header
from app.schemas import (
BlogPost, BlogPostDetail, ChartData, CompareRequest, CostCalculatorRequest,
CostCalculatorResponse, Destination, FAQ, SearchResult, StatsResponse,
CostCalculatorResponse, Destination, Discussion, DiscussionDetail, FAQ,
Meetup, MeetupRsvpRequest, MeetupSession, NextStopRequest, NextStopResponse, NomadRoute,
DigitalLesson, DigitalJob,
RecommendedDestination, SearchResult, StatsResponse,
SubscribeRequest, SubscribeResponse, Testimonial, Tool, Visa,
)
from app.services.pocketbase import pb
from app.data import mock_data
from app.data import community_data
from app.services.recommendations import recommend_destinations
from app.services.meetup_live import (
build_lounge_chat_url, build_mirotalk_join_url, can_access_meetup,
create_lounge_channel, create_mirotalk_room_slug,
)
from app.services.auth import get_user_by_token
from app.services import community_store, social_store
from app.services import city_service
from app.data import digital_content
router = APIRouter()
@ -53,6 +66,24 @@ async def get_destination(slug: str):
return Destination(**data)
@router.get("/destinations/{slug}/full")
async def get_destination_full(slug: str):
"""City depth: guide / cost / reviews / related meetups & content."""
data = await city_service.get_destination_full(slug)
if not data:
raise HTTPException(404, "目的地不存在")
return data
@router.get("/cities/{slug}")
async def city_detail_compat(slug: str):
"""NomadCNA-compatible city detail payload."""
data = await city_service.get_destination_full(slug)
if not data:
raise HTTPException(404, "城市不存在")
return data
@router.post("/destinations/compare", response_model=list[Destination])
async def compare_destinations(body: CompareRequest):
all_dests = await pb.get_destinations()
@ -213,3 +244,197 @@ async def global_search(q: str = Query(..., min_length=1)):
))
return results[:12]
# ── Community (ported from NomadCNA, adapted for nomadro) ──
@router.get("/meetups", response_model=list[Meetup])
async def list_meetups(upcoming: bool = True):
return [Meetup(**m) for m in community_store.list_meetups(upcoming)]
@router.get("/meetups/{meetup_id}", response_model=Meetup)
async def get_meetup(meetup_id: str):
m = community_store.get_meetup(meetup_id)
if not m:
raise HTTPException(404, "活动不存在")
return Meetup(**m)
@router.post("/meetups/rsvp")
async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "请先登录后再报名,以便保留名额")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "请先登录后再报名")
user_id = user["id"]
res = community_store.rsvp_meetup(body.meetup_id, user_id)
if not res.get("ok"):
if res.get("error") == "not_found":
raise HTTPException(404, "活动不存在")
raise HTTPException(400, "活动已满员")
meetup = community_store.get_meetup(body.meetup_id) or {}
title = meetup.get("title") or "活动"
mode = meetup.get("mode") or ""
organizer_id = meetup.get("organizer_id") or ""
from app.services.notify import notify_user
if organizer_id and organizer_id != user_id:
notify_user(
organizer_id,
"新的活动报名",
f"{user.get('name', '游民')} 报名了「{title}」",
"/meetups",
"meetup",
)
# Confirm to attendee with next-step deep link (live room or profile calendar)
if mode in ("online", "hybrid"):
notify_user(
user_id,
"报名成功",
f"你已报名「{title}」,活动开始时可进入直播间",
f"/meetups/{body.meetup_id}/live",
"meetup",
)
else:
notify_user(
user_id,
"报名成功",
f"你已报名「{title}」,可在个人中心导出日历",
"/profile",
"meetup",
)
return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]}
@router.get("/discussions", response_model=list[Discussion])
async def list_discussions(category: str | None = Query(None)):
return [Discussion(**d) for d in community_store.list_discussions(category)]
@router.get("/discussions/{discussion_id}", response_model=DiscussionDetail)
async def get_discussion(discussion_id: str):
d = community_store.get_discussion(discussion_id, increment_view=True)
if not d:
raise HTTPException(404, "讨论不存在")
return DiscussionDetail(**d)
@router.get("/routes", response_model=list[NomadRoute])
async def list_routes():
dests = await pb.get_destinations()
return [NomadRoute(**{k: v for k, v in r.items() if k != "cities"}) for r in city_service.list_routes_enriched(dests)]
@router.get("/routes/full")
async def list_routes_full():
dests = await pb.get_destinations()
return {"ok": True, "items": city_service.list_routes_enriched(dests)}
@router.post("/recommendations/next-stop", response_model=NextStopResponse)
async def next_stop_recommendations(body: NextStopRequest):
dests = await pb.get_destinations()
recommended = recommend_destinations(
dests,
budget=body.budget,
internet=body.internet,
climate=body.climate,
tags=body.tags,
priority=body.priority,
limit=body.limit,
)
routes = city_service.list_routes_enriched(dests)
return NextStopResponse(
items=[RecommendedDestination(**d) for d in recommended],
routes=[NomadRoute(**{k: v for k, v in r.items() if k != "cities"}) for r in routes],
)
@router.get("/meetups/{meetup_id}/session", response_model=MeetupSession)
async def meetup_session(meetup_id: str, authorization: str | None = Header(None)):
meetup = community_store.get_meetup(meetup_id)
if not meetup:
raise HTTPException(404, "活动不存在")
user = None
if authorization and authorization.startswith("Bearer "):
user = get_user_by_token(authorization[7:])
is_vip = social_store.is_vip(user["id"]) if user else False
can_join, lock = can_access_meetup(meetup, user, is_vip)
mode = meetup.get("mode", "offline")
display = (user or {}).get("name", "访客")
if mode not in ("online", "hybrid"):
can_join = False
lock = lock or "offline"
elif can_join:
# Live rooms need login + RSVP (organizer exempt)
if not user:
can_join = False
lock = "login_required"
else:
organizer_id = meetup.get("organizer_id") or ""
is_host = bool(organizer_id) and organizer_id == user["id"]
if not is_host:
rsvps = community_store.user_rsvp_ids(user["id"])
mid = meetup.get("id") or meetup_id
if mid not in rsvps and meetup_id not in rsvps:
can_join = False
lock = "rsvp_required"
session = MeetupSession(
canJoin=can_join,
lockReason=lock if not can_join else "",
displayName=display,
mode=mode,
mirotalkRoom=meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup),
loungeChannel=meetup.get("loungeChannel") or "",
)
if session.canJoin:
session.videoUrl = build_mirotalk_join_url(meetup, display)
session.chatUrl = build_lounge_chat_url(meetup)
if not session.loungeChannel:
session.loungeChannel = create_lounge_channel(meetup)
return session
# ── Digital academy content ──
@router.get("/digital/course")
async def digital_course():
return {"modules": digital_content.COURSE_MODULES, "siteId": digital_content.SITE_ID}
@router.get("/digital/lessons/{module_index}/{lesson_index}", response_model=DigitalLesson)
async def digital_lesson(module_index: int, lesson_index: int, authorization: str | None = Header(None)):
key = f"{module_index}-{lesson_index}"
lesson = digital_content.LESSONS.get(key)
if not lesson:
raise HTTPException(404, "课时不存在")
if not lesson.get("free"):
user = None
if authorization and authorization.startswith("Bearer "):
user = get_user_by_token(authorization[7:])
if not user or not social_store.is_vip(user["id"]):
raise HTTPException(403, "需要 VIP 会员解锁")
return DigitalLesson(**lesson)
@router.get("/digital/jobs", response_model=list[DigitalJob])
async def digital_jobs():
return [DigitalJob(**j) for j in digital_content.JOBS]
@router.get("/digital/vip/check")
async def digital_vip_check(authorization: str | None = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
return {"vip": False}
user = get_user_by_token(authorization[7:])
if not user:
return {"vip": False}
return {"vip": social_store.is_vip(user["id"])}

View File

@ -1,24 +1,44 @@
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
import httpx
from app.schemas import (
AuthLogin, AuthRegister, AuthResponse, Destination,
AuthLogin, AuthRegister, AuthResponse, Destination, GoogleLoginRequest,
WeChatLoginRequest,
FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile,
UserPlanPayload,
)
from app.services.auth import (
DEMO_TOKEN, get_favorites, get_user_by_token,
login_user, register_user, toggle_favorite,
get_favorites, get_plan, get_user_by_token,
login_demo, login_google, login_wechat, login_user, register_user, save_plan, toggle_favorite,
update_profile,
)
from app.services.wechat import code2session
from app.services.pocketbase import pb
from app.services import social_store
router = APIRouter(prefix="/auth", tags=["auth"])
def _with_social_seed(result: dict | None) -> dict | None:
if not result:
return result
try:
uid = (result.get("user") or {}).get("id")
if uid:
social_store.seed_demo_social_activity(uid)
except Exception:
pass
return result
@router.post("/register", response_model=AuthResponse)
async def register(body: AuthRegister):
result = register_user(body.email, body.password, body.name)
if not result:
raise HTTPException(400, "该邮箱已注册")
return AuthResponse(**result)
return AuthResponse(**_with_social_seed(result))
@router.post("/login", response_model=AuthResponse)
@ -26,7 +46,7 @@ async def login(body: AuthLogin):
result = login_user(body.email, body.password)
if not result:
raise HTTPException(401, "邮箱或密码错误")
return AuthResponse(**result)
return AuthResponse(**_with_social_seed(result))
@router.get("/me", response_model=UserProfile)
@ -38,13 +58,71 @@ async def me(authorization: str | None = Header(None)):
return UserProfile(**user)
class ProfileUpdateBody(BaseModel):
name: str | None = None
avatar: str | None = None
@router.patch("/me", response_model=UserProfile)
async def patch_me(body: ProfileUpdateBody, authorization: str | None = Header(None)):
token = _extract_token(authorization)
if not get_user_by_token(token):
raise HTTPException(401, "未登录")
# Empty patch → return current profile (used by refreshUser)
if body.name is None and body.avatar is None:
user = get_user_by_token(token)
return UserProfile(**user)
updated = update_profile(token, name=body.name, avatar=body.avatar)
if not updated:
raise HTTPException(400, "更新失败")
return UserProfile(**updated)
@router.get("/demo", response_model=AuthResponse)
async def demo_login():
"""一键体验演示账号"""
user = get_user_by_token(DEMO_TOKEN)
if not user:
result = login_demo()
if not result:
raise HTTPException(500, "演示账号不可用")
return AuthResponse(token=DEMO_TOKEN, user=UserProfile(**user))
return AuthResponse(**_with_social_seed(result))
@router.post("/google", response_model=AuthResponse)
async def google_login(body: GoogleLoginRequest):
try:
async with httpx.AsyncClient(timeout=10) as client:
res = await client.get(
"https://oauth2.googleapis.com/tokeninfo",
params={"id_token": body.id_token},
)
res.raise_for_status()
data = res.json()
except httpx.HTTPError:
raise HTTPException(401, "Google 登录验证失败")
email = data.get("email")
verified = data.get("email_verified")
if not email or verified not in (True, "true"):
raise HTTPException(401, "无效的 Google 账号")
name = data.get("name") or email.split("@")[0]
result = login_google(email, name)
if not result:
raise HTTPException(500, "登录失败")
return AuthResponse(**_with_social_seed(result))
@router.post("/wechat", response_model=AuthResponse)
async def wechat_login(body: WeChatLoginRequest):
"""微信小程序 wx.login code → jscode2session → 签发 nomadro token。"""
try:
session = await code2session(body.code)
except RuntimeError as e:
raise HTTPException(401, str(e)) from e
openid = session.get("openid") or ""
unionid = session.get("unionid") or None
result = login_wechat(openid, unionid)
if not result:
raise HTTPException(500, "微信登录失败")
return AuthResponse(**_with_social_seed(result))
@router.get("/favorites", response_model=FavoriteResponse)
@ -73,11 +151,14 @@ async def profile_stats(authorization: str | None = Header(None)):
if not user:
raise HTTPException(401, "未登录")
favs = get_favorites(token)
plan = get_plan(token) or {}
cities = len(plan.get("items") or [])
levels = [(0, "🌱 新手游民"), (1, "🎒 背包客"), (2, "✈️ 飞行游民"), (3, "🌍 环球游民")]
level = levels[min(len(favs), 3)][1]
explored = max(len(favs), cities)
level = levels[min(explored, 3)][1]
return ProfileStats(
favorites_count=len(favs),
destinations_explored=len(favs),
destinations_explored=explored,
member_since="2026",
nomad_level=level,
)
@ -95,6 +176,33 @@ async def add_favorite(body: FavoriteRequest, authorization: str | None = Header
return FavoriteResponse(slugs=slugs)
@router.get("/plan", response_model=UserPlanPayload)
async def read_plan(authorization: str | None = Header(None)):
token = _extract_token(authorization)
if not get_user_by_token(token):
raise HTTPException(401, "未登录")
plan = get_plan(token)
if not plan:
raise HTTPException(401, "未登录")
return UserPlanPayload(**plan)
@router.put("/plan", response_model=UserPlanPayload)
async def write_plan(body: UserPlanPayload, authorization: str | None = Header(None)):
token = _extract_token(authorization)
if not get_user_by_token(token):
raise HTTPException(401, "未登录")
saved = save_plan(
token,
[item.model_dump() for item in body.items],
body.meta.model_dump(),
body.updated_at or None,
)
if not saved:
raise HTTPException(401, "未登录")
return UserPlanPayload(**saved)
def _extract_token(authorization: str | None) -> str:
if not authorization:
return ""

View File

@ -0,0 +1,170 @@
"""Community mutations — discussions, meetups, gigs, notifications."""
from fastapi import APIRouter, Header, HTTPException, Query
from app.schemas import (
CreateDiscussionRequest, CreateGigRequest, CreateMeetupRequest, CreateReplyRequest,
FeedbackRequest, GigApplyRequest, GigItem, NotificationItem, StatsOverview,
)
from app.services.auth import get_user_by_token
from app.services import community_store, social_store
router = APIRouter(tags=["community"])
def _user(authorization: str | None) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "未登录")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "未登录")
return user
@router.post("/discussions")
async def create_discussion(body: CreateDiscussionRequest, authorization: str | None = Header(None)):
user = _user(authorization)
d = community_store.create_discussion(user["id"], user["name"], body.model_dump())
return {"success": True, "discussion": d}
@router.post("/discussions/{discussion_id}/replies")
async def post_reply(
discussion_id: str,
body: CreateReplyRequest,
authorization: str | None = Header(None),
):
user = _user(authorization)
reply = community_store.add_reply(discussion_id, user["id"], user["name"], body.content, body.author_emoji)
if not reply:
raise HTTPException(404, "讨论不存在")
discussion = community_store.get_discussion(discussion_id, increment_view=False) or {}
author_id = discussion.get("author_id") or ""
if author_id and author_id != user["id"]:
from app.services.notify import notify_user
notify_user(
author_id,
"讨论有新回复",
f"{user['name']} 回复了「{discussion.get('title', '话题')}」",
f"/community/{discussion_id}",
"community",
)
return {"success": True, "reply": reply}
@router.post("/discussions/{discussion_id}/like")
async def like_discussion(discussion_id: str, authorization: str | None = Header(None)):
user = _user(authorization)
return community_store.toggle_discussion_like(discussion_id, user["id"])
@router.post("/discussions/{discussion_id}/pin")
async def pin_discussion(discussion_id: str, authorization: str | None = Header(None)):
user = _user(authorization)
d = community_store.toggle_pin(discussion_id, user["id"])
if not d:
raise HTTPException(403, "仅作者可置顶")
return {"success": True, "discussion": d}
@router.post("/meetups")
async def create_meetup(body: CreateMeetupRequest, authorization: str | None = Header(None)):
user = _user(authorization)
m = community_store.create_meetup(user["id"], user["name"], body.model_dump())
return {"success": True, "meetup": m}
@router.get("/meetups/my-rsvp")
async def my_rsvps(authorization: str | None = Header(None)):
user = _user(authorization)
return {"ids": list(community_store.user_rsvp_ids(user["id"]))}
@router.get("/gigs", response_model=list[GigItem])
async def list_gigs():
return [GigItem(**g) for g in community_store.list_gigs()]
@router.post("/gigs")
async def create_gig(body: CreateGigRequest, authorization: str | None = Header(None)):
user = _user(authorization)
g = community_store.create_gig(user["id"], user["name"], body.model_dump())
return {"success": True, "gig": g}
@router.post("/gigs/{gig_id}/apply")
async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | None = Header(None)):
user = _user(authorization)
res = community_store.apply_gig(gig_id, user["id"], user["name"], body.message)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "申请失败"))
poster_id = res.get("poster_id") or ""
if not poster_id:
email = (res.get("poster_email") or "").strip().lower()
if email:
from app.services.auth import find_user_public_by_email
peer = find_user_public_by_email(email)
if peer:
poster_id = peer["id"]
if poster_id and poster_id != user["id"]:
from app.services.notify import notify_user
notify_user(
poster_id,
"赏金任务有新申请",
f"{user['name']} 申请了「{res.get('title') or '任务'}」",
"/gigs",
"gig",
)
return {"ok": True, "message": res.get("message", "申请已提交")}
@router.get("/notifications", response_model=list[NotificationItem])
async def list_notifications(authorization: str | None = Header(None)):
user = _user(authorization)
return [NotificationItem(**n) for n in community_store.list_notifications(user["id"])]
@router.post("/notifications/read")
async def mark_read(authorization: str | None = Header(None)):
user = _user(authorization)
community_store.mark_notifications_read(user["id"])
return {"success": True}
@router.post("/notifications/{notif_id}/{action}")
async def notification_action(notif_id: str, action: str, authorization: str | None = Header(None)):
user = _user(authorization)
if action not in ("read", "unread", "archive", "restore", "pin", "unpin"):
raise HTTPException(400, "无效操作")
n = community_store.update_notification(user["id"], notif_id, action)
if not n:
raise HTTPException(404, "通知不存在")
return {"success": True, "notification": n}
@router.post("/feedback")
async def submit_feedback(body: FeedbackRequest, authorization: str | None = Header(None)):
user = None
name = body.name or "访客"
if authorization and authorization.startswith("Bearer "):
user = get_user_by_token(authorization[7:])
if user:
name = user["name"]
item = community_store.add_feedback(user["id"] if user else None, name, body.content, body.category)
return {"success": True, "id": item["id"]}
@router.get("/stats/overview", response_model=StatsOverview)
async def stats_overview():
return StatsOverview(**community_store.stats_overview())
@router.get("/members/{user_id}")
async def public_member(user_id: str):
profile = social_store.get_public_profile(user_id)
if not profile:
raise HTTPException(404, "用户不存在")
return profile

View File

@ -0,0 +1,58 @@
"""Ebook purchase entitlements + download links (FastAPI only)."""
from __future__ import annotations
from fastapi import APIRouter, Header, HTTPException
from app.services.auth import get_user_by_token
from app.services import social_store
from app.services.payment_service import EBOOK_EPUB_URL, EBOOK_PDF_URL, PAYMENT_EBOOK_AMOUNT
router = APIRouter(prefix="/ebook", tags=["ebook"])
def _user(authorization: str | None) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "请先登录")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "请先登录")
return user
@router.get("/status")
async def ebook_status(authorization: str | None = Header(None)):
user = _user(authorization)
owned = social_store.has_ebook(user["id"])
has_files = bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)
return {
"owned": owned,
"has_files": has_files,
"amount_fen": PAYMENT_EBOOK_AMOUNT,
"amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}",
}
@router.get("/product")
async def ebook_product():
"""Public product info — no auth (buy CTA honesty)."""
has_files = bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)
return {
"has_files": has_files,
"amount_fen": PAYMENT_EBOOK_AMOUNT,
"amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}",
}
@router.get("/downloads")
async def ebook_downloads(authorization: str | None = Header(None)):
user = _user(authorization)
if not social_store.has_ebook(user["id"]):
raise HTTPException(403, "尚未购买电子书下载版")
files = []
if EBOOK_PDF_URL:
files.append({"format": "pdf", "label": "PDF", "url": EBOOK_PDF_URL})
if EBOOK_EPUB_URL:
files.append({"format": "epub", "label": "EPUB", "url": EBOOK_EPUB_URL})
if not files:
files.append({"format": "online", "label": "Online reading", "url": "/book/read"})
return {"owned": True, "files": files, "has_files": bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)}

View File

@ -0,0 +1,58 @@
"""Media upload — FastAPI only, stored on S3 (SeaweedFS)."""
from __future__ import annotations
from fastapi import APIRouter, File, Header, HTTPException, UploadFile
from app.services.auth import get_user_by_token
from app.services import s3_storage
from app.services.pb_client import pb
router = APIRouter(tags=["media"])
MAX_IMAGE = 10 * 1024 * 1024
MAX_VIDEO = 200 * 1024 * 1024
def _user(authorization: str | None) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "未登录")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "未登录")
return user
@router.post("/upload-media")
async def upload_media(
file: UploadFile = File(...),
purpose: str = "general",
authorization: str | None = Header(None),
):
user = _user(authorization)
ctype = (file.content_type or "").lower()
max_size = MAX_VIDEO if ctype.startswith("video/") else MAX_IMAGE
result = await s3_storage.upload_file(file, purpose=purpose, max_size=max_size)
if pb.health_ok():
try:
pb.create_record(
"media_assets",
{
"userId": user["id"],
"url": result.url,
"filename": file.filename or "",
"contentType": result.content_type,
"size": result.size,
"objectKey": result.object_key,
},
)
except Exception:
pass
return {
"success": True,
"url": result.url,
"object_key": result.object_key,
"size": result.size,
"content_type": result.content_type,
}

View File

@ -0,0 +1,86 @@
"""Meetup auth helpers — NomadCNA check-user / ensure-user / complete-order."""
from __future__ import annotations
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel, EmailStr, Field
from app.services.auth import ensure_user_by_email, find_user_public_by_email, get_user_by_token
from app.services import social_store
router = APIRouter(prefix="/meetup", tags=["meetup-auth"])
class CheckUserPayload(BaseModel):
email: EmailStr
class EnsureUserPayload(BaseModel):
email: EmailStr
name: str = Field(default="", max_length=120)
class CompleteOrderPayload(BaseModel):
order_id: str = Field(min_length=4, max_length=160)
@router.post("/check-user")
async def check_user(payload: CheckUserPayload):
user = find_user_public_by_email(str(payload.email).lower())
vip = bool(user and social_store.is_vip(user["id"]))
return {
"ok": True,
"exists": bool(user),
"vip": vip,
"user_id": user["id"] if user else None,
}
@router.post("/ensure-user")
async def ensure_user(payload: EnsureUserPayload):
try:
result = ensure_user_by_email(str(payload.email).lower(), payload.name)
except Exception as exc:
raise HTTPException(500, f"创建用户失败: {exc}") from exc
user = result["user"]
vip = social_store.is_vip(user["id"])
return {
"ok": True,
"user_id": user["id"],
"record": user,
"token": result["token"],
"is_new": bool(result.get("is_new")),
"vip": vip,
}
@router.post("/complete-order")
async def complete_order(payload: CompleteOrderPayload, authorization: str | None = Header(None)):
order_id = payload.order_id.strip()
if not order_id:
raise HTTPException(400, "缺少 order_id")
order = social_store.get_order(order_id)
if not order:
raise HTTPException(404, "订单不存在")
if order.get("status") != "paid":
paid = social_store.mark_order_paid(order_id)
if not paid:
raise HTTPException(400, "订单未支付成功")
order = paid
user = None
token = None
if authorization and authorization.startswith("Bearer "):
user = get_user_by_token(authorization[7:])
token = authorization[7:]
if not user:
uid = order.get("userId") or order.get("user_id") or ""
if uid:
user = {"id": uid, "email": "", "name": "会员", "avatar": "🧑‍💻"}
return {
"ok": True,
"token": token,
"record": user,
"is_new": False,
"vip": True,
"order_id": order_id,
}

View File

@ -0,0 +1,145 @@
"""Payment + order fulfillment — ZPay/XorPay from NomadCNA."""
from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from app.schemas import PayCreateRequest, PayCreateResponse, PayStatusResponse
from app.services.auth import get_user_by_token
from app.services import social_store
from app.services.payment_service import (
DEV_AUTO_PAY,
PAYMENT_DEFAULT_AMOUNT,
PAYMENT_EBOOK_AMOUNT,
PAYMENT_JOIN_AMOUNT,
create_pay_redirect,
parse_notify_order_id,
query_order_paid,
verify_notify,
)
router = APIRouter(prefix="/pay", tags=["payment"])
def _token_user(authorization: str | None) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "未登录")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "未登录")
return user
def _client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return ""
@router.post("/create", response_model=PayCreateResponse)
async def create_payment(
body: PayCreateRequest,
request: Request,
authorization: str | None = Header(None),
):
user = _token_user(authorization)
if body.pay_type == "ebook":
amount = PAYMENT_EBOOK_AMOUNT
product_name = "nomadro电子书下载版"
elif body.pay_type in ("join", "vip"):
amount = PAYMENT_JOIN_AMOUNT
product_name = "nomadro会员"
else:
amount = PAYMENT_DEFAULT_AMOUNT
product_name = "nomadro订单"
order = social_store.create_order(user["id"], body.pay_type, amount, dev_auto_pay=DEV_AUTO_PAY)
return_url = body.return_url or "https://nomadweb.nomadro.com/join/paid"
ua = request.headers.get("user-agent", "")
device = "wechat" if "micromessenger" in ua.lower() else "pc"
try:
redirect = await create_pay_redirect(
order["id"],
amount,
return_url,
body.provider or "",
channel="wxpay" if device == "wechat" else "alipay",
device=device,
user_agent=ua,
client_ip=_client_ip(request),
product_name=product_name,
)
except ValueError as exc:
raise HTTPException(503, str(exc)) from exc
return PayCreateResponse(
order_id=order["id"],
amount=amount,
redirect_url=redirect,
status=order["status"],
)
@router.get("/status", response_model=PayStatusResponse)
async def pay_status(order_id: str = Query(...), authorization: str | None = Header(None)):
_token_user(authorization)
order = social_store.get_order(order_id)
if not order:
raise HTTPException(404, "订单不存在")
if order["status"] != "paid":
if DEV_AUTO_PAY or await query_order_paid(order_id):
social_store.mark_order_paid(order_id)
order = social_store.get_order(order_id)
return PayStatusResponse(
order_id=order_id,
status=order["status"],
paid=order["status"] == "paid",
)
@router.post("/complete")
async def complete_order(
order_id: str = Query(...),
authorization: str | None = Header(None),
):
user = _token_user(authorization)
order = social_store.get_order(order_id)
if not order:
raise HTTPException(404, "订单不存在")
if order["userId"] != user["id"]:
raise HTTPException(403, "无权操作此订单")
if order["status"] != "paid":
if DEV_AUTO_PAY or await query_order_paid(order_id):
social_store.mark_order_paid(order_id)
else:
raise HTTPException(402, "订单未支付")
return {"success": True, "vip": social_store.is_vip(user["id"]), "ebook": social_store.has_ebook(user["id"])}
async def _notify_handler(provider: str, request: Request) -> PlainTextResponse:
if request.method == "GET":
data = dict(request.query_params)
else:
form = await request.form()
data = dict(form)
if not verify_notify(provider, data):
raise HTTPException(400, "验签失败")
oid = parse_notify_order_id(provider, data)
if oid:
social_store.mark_order_paid(oid)
return PlainTextResponse("success" if provider == "zpay" else "ok")
@router.api_route("/zpay_notify", methods=["GET", "POST"])
async def zpay_notify(request: Request):
return await _notify_handler("zpay", request)
@router.api_route("/xorpay_notify", methods=["GET", "POST"])
async def xorpay_notify(request: Request):
return await _notify_handler("xorpay", request)
@router.api_route("/notify", methods=["GET", "POST"])
async def pay_notify_legacy(request: Request, provider: str = Query("zpay")):
return await _notify_handler(provider, request)

View File

@ -0,0 +1,185 @@
"""Platform APIs: services, videos, weather, AI, stats, content."""
from fastapi import APIRouter, Header, HTTPException, Query
from app.schemas import (
AiAssistantRequest, AiAssistantResponse, ContentSubmissionRequest,
NewsletterRequest, ReportRequest, ServiceItem, ServiceLeadRequest,
VideoItem, VolunteerRequest,
)
from app.data import platform_data
from app.services import community_store, social_store
from app.services.auth import get_user_by_token
from app.services import platform_store
from app.services.weather_service import get_city_weather, list_weather
from app.services.ai_service import assistant_reply
from app.services.pocketbase import pb
router = APIRouter(tags=["platform"])
def _optional_user(authorization: str | None) -> dict | None:
if authorization and authorization.startswith("Bearer "):
return get_user_by_token(authorization[7:])
return None
def _user(authorization: str | None) -> dict:
u = _optional_user(authorization)
if not u:
raise HTTPException(401, "未登录")
return u
@router.get("/services", response_model=list[ServiceItem])
async def list_services():
return [ServiceItem(**s) for s in platform_data.SERVICES]
@router.post("/services/leads")
async def service_lead(body: ServiceLeadRequest, authorization: str | None = Header(None)):
user = _optional_user(authorization)
item = platform_store.add_service_lead(
body.service_id,
user["id"] if user else None,
body.name,
body.email,
body.message,
)
return {"success": True, "id": item["id"]}
@router.get("/videos", response_model=list[VideoItem])
async def list_videos():
return [VideoItem(**v) for v in platform_data.VIDEOS]
@router.get("/videos/{slug}", response_model=VideoItem)
async def get_video(slug: str):
v = next((x for x in platform_data.VIDEOS if x["slug"] == slug), None)
if not v:
raise HTTPException(404, "视频不存在")
return VideoItem(**v)
@router.get("/content/home")
async def content_home():
return {
"featured": platform_data.VIDEOS[:2],
"videos_count": len(platform_data.VIDEOS),
"services_count": len(platform_data.SERVICES),
}
@router.post("/content/submissions")
async def content_submission(body: ContentSubmissionRequest, authorization: str | None = Header(None)):
user = _optional_user(authorization)
item = platform_store.add_content_submission(
user["id"] if user else None,
body.name,
body.title,
body.content_type,
body.url,
body.notes,
)
return {"success": True, "id": item["id"]}
@router.get("/weather/cities")
async def weather_cities():
return await list_weather()
@router.get("/weather/cities/{slug}")
async def weather_city(slug: str):
w = await get_city_weather(slug)
if not w:
raise HTTPException(404, "城市不存在")
return w
@router.post("/ai/assistant", response_model=AiAssistantResponse)
async def ai_assistant(body: AiAssistantRequest):
dests = await pb.get_destinations()
result = assistant_reply(body.message, dests)
return AiAssistantResponse(**result)
@router.post("/newsletter/subscribe")
async def newsletter_subscribe(body: NewsletterRequest):
res = platform_store.subscribe_newsletter(body.email, body.source)
return res
@router.get("/stats/member-map")
async def member_map():
return {"members": platform_data.MEMBER_MAP}
@router.get("/stats/city-ranking")
async def city_ranking():
return {"items": platform_data.CITY_RANKING}
@router.post("/cities/{slug}/volunteer-applications")
async def volunteer_apply(slug: str, body: VolunteerRequest, authorization: str | None = Header(None)):
user = _optional_user(authorization)
item = platform_store.add_volunteer(slug, user["id"] if user else None, body.name, body.email, body.message)
return {"success": True, "id": item["id"]}
@router.post("/reports")
async def submit_report(body: ReportRequest, authorization: str | None = Header(None)):
user = _optional_user(authorization)
item = platform_store.add_report(
user["id"] if user else None,
body.target_type,
body.target_id,
body.reason,
)
return {"success": True, "id": item["id"]}
@router.get("/digital/day/{day_id}")
async def digital_day(day_id: str):
tip = platform_data.DAILY_TIPS.get(day_id)
if not tip:
raise HTTPException(404, "每日指南不存在")
return {"id": day_id, **tip}
@router.get("/notifications/unread-count")
async def unread_count(authorization: str | None = Header(None)):
user = _user(authorization)
return {"count": community_store.notification_unread_count(user["id"])}
@router.get("/notifications/preferences")
async def get_notif_prefs(authorization: str | None = Header(None)):
user = _user(authorization)
return platform_store.get_notif_prefs(user["id"])
@router.put("/notifications/preferences")
async def set_notif_prefs(body: dict, authorization: str | None = Header(None)):
user = _user(authorization)
return platform_store.set_notif_prefs(user["id"], body)
@router.get("/discussions/search")
async def search_discussions(q: str = Query(..., min_length=1)):
return community_store.search_discussions(q)
@router.get("/meetups/{meetup_id}/social-suggestions")
async def meetup_social(meetup_id: str):
return {"items": community_store.meetup_social_suggestions(meetup_id)}
@router.delete("/meetups/{meetup_id}/rsvp")
async def cancel_rsvp(meetup_id: str, authorization: str | None = Header(None)):
user = _user(authorization)
res = community_store.cancel_rsvp(meetup_id, user["id"])
if not res.get("ok"):
raise HTTPException(400, "未报名此活动")
return res

View File

@ -0,0 +1,167 @@
"""Matching, chat, join, VIP — adapted from NomadCNA."""
from fastapi import APIRouter, Header, HTTPException, Query
from app.schemas import (
ChatMessage, ConversationItem, JoinMemberRequest, MatchProfile, MatchQuota,
SendMessageRequest, SwipeRequest, SwipeResponse, VipStatus,
)
from app.services.auth import get_user_by_token
from app.services import social_store
router = APIRouter(prefix="/social", tags=["social"])
def _token_user(authorization: str | None) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "未登录")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "未登录")
return user
@router.post("/join")
async def join_member(body: JoinMemberRequest, authorization: str | None = Header(None)):
user = _token_user(authorization)
profile = social_store.join_member(user["id"], user["name"], body.model_dump())
return {"success": True, "profile": _profile_payload(profile, user["id"])}
@router.get("/join/status")
async def join_status(authorization: str | None = Header(None)):
user = _token_user(authorization)
profile = social_store.get_public_profile(user["id"])
return {"complete": bool(profile), "profile": _profile_payload(profile, user["id"]) if profile else None}
def _profile_payload(profile: dict | None, user_id: str) -> dict:
if not profile:
return {}
return {
"id": profile.get("id") or f"user-{user_id}",
"userId": profile.get("userId") or user_id,
"name": profile.get("name") or "游民",
"location": profile.get("location") or "",
"citySlug": profile.get("citySlug") or "",
"gender": profile.get("gender") or "",
"single": profile.get("single") or "",
"bio": profile.get("bio") or "",
"photo": profile.get("photo") or "🧑‍💻",
"tags": profile.get("tags") or [],
"lookingFor": profile.get("lookingFor") or profile.get("looking_for") or [],
}
def _has_match_profile(user_id: str) -> bool:
return social_store.get_public_profile(user_id) is not None
@router.get("/matches/candidates", response_model=list[MatchProfile])
async def match_candidates(
authorization: str | None = Header(None),
intent: str = Query("friends"),
city: str = Query(""),
gender: str = Query(""),
single: str = Query(""),
exclude_swiped: bool = Query(True),
):
user = _token_user(authorization)
if not _has_match_profile(user["id"]):
# Soft-join so dating is usable after login without a stuck gate
social_store.join_member(
user["id"],
user.get("name") or "游民",
{"city": "全球", "lookingFor": [intent, "explore"], "bio": "刚加入 nomadro 匹配"},
)
return [MatchProfile(**p) for p in social_store.list_candidates(
user["id"], intent=intent, city=city, gender=gender, single=single, exclude_swiped=exclude_swiped
)]
@router.get("/matches/quota", response_model=MatchQuota)
async def match_quota(authorization: str | None = Header(None)):
user = _token_user(authorization)
return MatchQuota(**social_store.get_quota(user["id"]))
@router.post("/matches/swipes/undo")
async def undo_swipe(authorization: str | None = Header(None)):
user = _token_user(authorization)
res = social_store.undo_last_swipe(user["id"])
if not res.get("ok"):
raise HTTPException(400, "没有可撤销的滑动")
return res
@router.get("/matches/likes/received", response_model=list[MatchProfile])
async def match_likes_received(authorization: str | None = Header(None)):
user = _token_user(authorization)
return [MatchProfile(**p) for p in social_store.list_likes_received(user["id"])]
@router.get("/matches/likes", response_model=list[MatchProfile])
async def match_likes(authorization: str | None = Header(None)):
user = _token_user(authorization)
return [MatchProfile(**p) for p in social_store.list_likes(user["id"])]
@router.get("/matches/mutual")
async def mutual_matches(authorization: str | None = Header(None)):
user = _token_user(authorization)
return {"items": social_store.list_mutual(user["id"])}
@router.post("/matches/swipes", response_model=SwipeResponse)
async def record_swipe(body: SwipeRequest, authorization: str | None = Header(None)):
user = _token_user(authorization)
result = social_store.record_swipe(user["id"], body.profileId, body.action, body.intent)
if not result.get("ok"):
err = result.get("error")
if err == "quota_exceeded":
raise HTTPException(429, "今日滑动次数已用完")
if err == "vip_required":
raise HTTPException(403, "超级喜欢需要 VIP")
if err == "duplicate":
raise HTTPException(409, "已滑动过该用户")
raise HTTPException(400, err or "滑动失败")
return SwipeResponse(**result)
@router.get("/conversations", response_model=list[ConversationItem])
async def list_conversations(authorization: str | None = Header(None)):
user = _token_user(authorization)
items = social_store.list_conversations(user["id"])
return [ConversationItem(**c) for c in items]
@router.get("/conversations/{conv_id}")
async def get_conversation(conv_id: str, authorization: str | None = Header(None)):
user = _token_user(authorization)
conv = social_store.get_conversation(conv_id, user["id"])
if not conv:
raise HTTPException(404, "会话不存在")
return conv
@router.get("/conversations/{conv_id}/messages", response_model=list[ChatMessage])
async def get_messages(conv_id: str, authorization: str | None = Header(None), limit: int = 50):
user = _token_user(authorization)
return [ChatMessage(**m) for m in social_store.list_messages(conv_id, user["id"], limit)]
@router.post("/conversations/{conv_id}/messages", response_model=ChatMessage)
async def post_message(conv_id: str, body: SendMessageRequest, authorization: str | None = Header(None)):
user = _token_user(authorization)
msg = social_store.send_message(conv_id, user["id"], body.body)
if not msg:
raise HTTPException(400, "发送失败")
return ChatMessage(**msg)
@router.get("/vip/check", response_model=VipStatus)
async def vip_check(authorization: str | None = Header(None)):
user = _token_user(authorization)
vip = social_store.is_vip(user["id"])
exp = social_store.membership_expires_at(user["id"])
return VipStatus(vip=vip, expires_at=exp)

View File

@ -156,9 +156,401 @@ class ProfileStats(BaseModel):
nomad_level: str
class TripItemModel(BaseModel):
slug: str
name: str
country: str
emoji: str
cost: float
months: int = 1
note: str = ""
class PlanMetaModel(BaseModel):
title: str = "我的旅居计划"
startMonth: str = ""
monthlyBudget: float = 0
checklist: dict[str, bool] = Field(default_factory=dict)
class UserPlanPayload(BaseModel):
items: list[TripItemModel] = Field(default_factory=list)
meta: PlanMetaModel = Field(default_factory=PlanMetaModel)
updated_at: int = 0
class SearchResult(BaseModel):
type: str # destination | blog | visa | faq
title: str
subtitle: str
emoji: str
url: str
class Meetup(BaseModel):
id: str
title: str
city: str
destination_slug: str = ""
emoji: str
date: str
time: str
venue: str
description: str
mode: str # online | offline | hybrid
access_level: str = "public"
mirotalkRoom: str = ""
loungeChannel: str = ""
rsvp_count: int = 0
max_attendees: int = 50
organizer: str = ""
tags: list[str] = []
is_upcoming: bool = True
class Discussion(BaseModel):
id: str
title: str
excerpt: str
author: str
author_emoji: str = "🧑‍💻"
category: str
reply_count: int = 0
like_count: int = 0
is_pinned: bool = False
created_at: str
tags: list[str] = []
class DiscussionReply(BaseModel):
id: str
author: str
author_emoji: str = "🧑‍💻"
content: str
created_at: str
like_count: int = 0
class DiscussionDetail(Discussion):
replies: list[DiscussionReply] = []
class NomadRoute(BaseModel):
id: str
slug: str
title: str
duration_days: int
budget: int
description: str
stops: list[str]
emoji: str = "🗺️"
class NextStopRequest(BaseModel):
budget: int = Field(default=8000, ge=2000, le=50000)
internet: int = Field(default=50, ge=10, le=500)
climate: str = "mild" # warm | mild | cool
tags: list[str] = Field(default_factory=list)
priority: str = "balanced" # cost | speed | community | balanced
limit: int = Field(default=12, ge=1, le=24)
class RecommendedDestination(Destination):
match_score: float = 0
match_reasons: list[str] = []
class NextStopResponse(BaseModel):
items: list[RecommendedDestination]
routes: list[NomadRoute] = []
class MeetupRsvpRequest(BaseModel):
meetup_id: str
name: str = Field(default="访客", min_length=1)
# ── Social / matching / chat ──
class MatchProfile(BaseModel):
id: str
userId: str = ""
name: str
location: str = ""
citySlug: str = ""
gender: str = ""
single: str = ""
bio: str = ""
photo: str = "🧑‍💻"
tags: list[str] = []
lookingFor: list[str] = []
intent: str = ""
class SwipeRequest(BaseModel):
profileId: str
action: str = "like" # like | dislike | superlike
intent: str = "friends"
class SwipeResponse(BaseModel):
ok: bool
matched: bool = False
match: dict | None = None
error: str | None = None
class MatchQuota(BaseModel):
vip: bool
limit: int
used: int
remaining: int
class JoinMemberRequest(BaseModel):
city: str = ""
citySlug: str = ""
gender: str = ""
single: str = ""
bio: str = ""
lookingFor: list[str] = Field(default_factory=lambda: ["friends", "explore"])
photo: str = "🧑‍💻"
class ChatPeer(BaseModel):
id: str = ""
userId: str = ""
name: str = ""
photo: str = "🧑‍💻"
location: str = ""
class ConversationItem(BaseModel):
id: str
type: str = "direct"
intent: str = ""
lastMessageAt: str = ""
lastMessagePreview: str = ""
unreadCount: int = 0
peer: MatchProfile | None = None
class ChatMessage(BaseModel):
id: str
conversationId: str = ""
senderId: str
body: str
createdAt: str = ""
mine: bool = False
class SendMessageRequest(BaseModel):
body: str = Field(min_length=1, max_length=2000)
class VipStatus(BaseModel):
vip: bool
expires_at: int = 0
class PayCreateRequest(BaseModel):
pay_type: str = "join" # join | vip | meetup
return_url: str = ""
provider: str = "zpay"
class PayCreateResponse(BaseModel):
order_id: str
amount: int
redirect_url: str
status: str
class PayStatusResponse(BaseModel):
order_id: str
status: str
paid: bool
class MeetupSession(BaseModel):
canJoin: bool
lockReason: str = ""
displayName: str = ""
videoUrl: str = ""
chatUrl: str = ""
loungeChannel: str = ""
mirotalkRoom: str = ""
meetingProvider: str = "mirotalk"
mode: str = ""
class DigitalLesson(BaseModel):
title: str
moduleIndex: int
lessonIndex: int
duration: str = ""
free: bool = False
content: str = ""
class DigitalJob(BaseModel):
id: str
title: str
company: str
location: str
type: str
salary: str
tags: list[str] = []
url: str = ""
class CreateDiscussionRequest(BaseModel):
title: str = Field(min_length=4, max_length=120)
excerpt: str = ""
content: str = ""
category: str = "社区"
tags: list[str] = Field(default_factory=list)
author_emoji: str = "🧑‍💻"
class CreateReplyRequest(BaseModel):
content: str = Field(min_length=1, max_length=4000)
author_emoji: str = "🧑‍💻"
class CreateMeetupRequest(BaseModel):
title: str = Field(min_length=4, max_length=120)
city: str = "线上"
destination_slug: str = ""
emoji: str = "🎉"
date: str = ""
time: str = "19:00"
venue: str = ""
description: str = ""
mode: str = "offline"
access_level: str = "public"
max_attendees: int = 30
tags: list[str] = Field(default_factory=list)
mirotalkRoom: str = ""
loungeChannel: str = ""
class GigItem(BaseModel):
id: str
title: str
description: str
budget: str
deadline: str
tags: list[str] = []
poster: str = ""
status: str = "open"
class GigApplyRequest(BaseModel):
message: str = Field(min_length=4, max_length=2000)
class NotificationItem(BaseModel):
id: str
title: str
body: str = ""
link: str = ""
read: bool = False
archived: bool = False
pinned: bool = False
category: str = "general"
created_at: str = ""
class CreateGigRequest(BaseModel):
title: str = Field(min_length=4, max_length=120)
description: str = Field(min_length=10, max_length=4000)
budget: str = "面议"
deadline: str = ""
tags: list[str] = Field(default_factory=list)
class ServiceItem(BaseModel):
id: str
title: str
description: str
emoji: str = "💼"
price: str = ""
category: str = ""
provider: str = ""
class ServiceLeadRequest(BaseModel):
service_id: str
name: str = Field(min_length=2, max_length=80)
email: EmailStr
message: str = Field(min_length=4, max_length=2000)
class VideoItem(BaseModel):
id: str
slug: str
title: str
excerpt: str = ""
emoji: str = "🎬"
duration: str = ""
guest: str = ""
city: str = ""
published_at: str = ""
tags: list[str] = Field(default_factory=list)
video_url: str = ""
class AiAssistantRequest(BaseModel):
message: str = Field(min_length=2, max_length=500)
class AiAssistantResponse(BaseModel):
reply: str
cities: list[dict] = Field(default_factory=list)
class NewsletterRequest(BaseModel):
email: EmailStr
source: str = "site"
class ContentSubmissionRequest(BaseModel):
name: str = Field(min_length=2, max_length=80)
title: str = Field(min_length=4, max_length=200)
content_type: str = "article"
url: str = ""
notes: str = ""
class VolunteerRequest(BaseModel):
name: str = Field(min_length=2, max_length=80)
email: EmailStr
message: str = Field(min_length=4, max_length=2000)
class ReportRequest(BaseModel):
target_type: str
target_id: str
reason: str = Field(min_length=4, max_length=1000)
class GoogleLoginRequest(BaseModel):
id_token: str
class WeChatLoginRequest(BaseModel):
code: str
class FeedbackRequest(BaseModel):
content: str = Field(min_length=4, max_length=4000)
category: str = "general"
name: str = ""
class StatsOverview(BaseModel):
meetups: int = 0
discussions: int = 0
gigs: int = 0
members_active: int = 0

View File

@ -0,0 +1,50 @@
"""Rule-based nomad assistant (no LLM)."""
from __future__ import annotations
KEYWORD_CITIES = {
"便宜": ["chiangmai", "mexico", "bali"],
"budget": ["chiangmai", "mexico", "bali"],
"快": ["tokyo", "barcelona", "lisbon"],
"网速": ["tokyo", "barcelona", "lisbon"],
"internet": ["tokyo", "barcelona", "lisbon"],
"暖": ["bali", "chiangmai", "mexico"],
"warm": ["bali", "chiangmai", "mexico"],
"冷": ["lisbon", "barcelona"],
"cool": ["lisbon", "barcelona"],
"签证": ["lisbon", "mexico"],
"visa": ["lisbon", "mexico"],
"中文": ["chiangmai", "bali"],
"chinese": ["chiangmai", "bali"],
}
def assistant_reply(message: str, destinations: list[dict]) -> dict:
msg = message.lower()
picks: list[str] = []
for kw, slugs in KEYWORD_CITIES.items():
if kw in msg:
picks.extend(slugs)
if not picks:
picks = ["chiangmai", "lisbon", "bali"]
seen: set[str] = set()
items = []
for slug in picks:
if slug in seen:
continue
seen.add(slug)
dest = next((d for d in destinations if d.get("slug") == slug), None)
if dest:
items.append({
"slug": slug,
"name": dest.get("name", slug),
"emoji": dest.get("emoji", "🌍"),
"reason": f"匹配你的需求:{message[:40]}",
})
if len(items) >= 3:
break
reply = (
f"根据你的描述,我推荐看看 {', '.join(i['name'] for i in items)}。"
" 可以用「下一站决策」做更精细的筛选,或在社区问当地细节。"
)
return {"reply": reply, "cities": items}

View File

@ -1,21 +1,174 @@
"""Simple auth service with in-memory fallback when PocketBase users unavailable."""
"""Auth + favorites + plans — PocketBase backed (nomad_accounts / nomad_sessions)."""
from __future__ import annotations
import hashlib
import json
import secrets
import time
from pathlib import Path
from typing import Any
from app.data import mock_data
from app.services.pb_client import pb, pb_quote
from app.services.pb_repo import safe_create, safe_first, safe_update, use_pb
# In-memory store for demo mode
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "user_store.json"
# In-memory fallback when PocketBase unavailable (dev only)
_users: dict[str, dict] = {}
_sessions: dict[str, str] = {} # token -> user_id
_favorites: dict[str, list[str]] = {} # user_id -> [slugs]
_sessions: dict[str, str] = {}
_favorites: dict[str, list[str]] = {}
_plans: dict[str, dict] = {}
def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def _empty_plan() -> dict[str, Any]:
return {
"items": [],
"meta": {
"title": "我的旅居计划",
"startMonth": "",
"monthlyBudget": 0,
"checklist": {},
},
"updated_at": 0,
}
def _user_profile(user: dict) -> dict:
return {
"id": user["id"],
"email": user["email"],
"name": user["name"],
"avatar": user.get("avatar", "🧑‍💻"),
}
def _account_from_pb(row: dict) -> dict:
return {
"pb_id": row["id"],
"id": row.get("legacyUserId") or row["id"],
"email": row.get("email", ""),
"password_hash": row.get("passwordHash", ""),
"name": row.get("name", ""),
"avatar": row.get("avatar", "🧑‍💻"),
"favorites": row.get("favorites") or [],
"plan": row.get("plan") or _empty_plan(),
}
def _migrate_json_to_pb() -> None:
if not STORE_PATH.exists() or not use_pb():
return
try:
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
if pb.list_records("nomad_accounts", per_page=1).get("totalItems", 0) > 0:
return
for email, user in (data.get("users") or {}).items():
uid = user.get("id", secrets.token_hex(8))
pb.create_record(
"nomad_accounts",
{
"email": email,
"passwordHash": user.get("password_hash", ""),
"name": user.get("name", ""),
"avatar": user.get("avatar", "🧑‍💻"),
"legacyUserId": uid,
"favorites": data.get("favorites", {}).get(uid, []),
"plan": data.get("plans", {}).get(uid, _empty_plan()),
},
)
for token, uid in (data.get("sessions") or {}).items():
pb.create_record(
"nomad_sessions",
{"token": token, "userId": uid, "expiresAt": int(time.time()) + 86400 * 30},
)
def _restore_json() -> bool:
if not STORE_PATH.exists():
return False
try:
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
global _users, _sessions, _favorites, _plans
_users = data.get("users") or {}
_sessions = data.get("sessions") or {}
_favorites = data.get("favorites") or {}
_plans = data.get("plans") or {}
return True
def _persist_json() -> None:
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
STORE_PATH.write_text(
json.dumps({
"users": _users,
"sessions": _sessions,
"favorites": _favorites,
"plans": _plans,
}, ensure_ascii=False),
encoding="utf-8",
)
def _get_account_by_email(email: str) -> dict | None:
row = safe_first("nomad_accounts", filter=f"email={pb_quote(email)}")
return _account_from_pb(row) if row else None
def _get_account_by_id(user_id: str) -> dict | None:
row = safe_first("nomad_accounts", filter=f"legacyUserId={pb_quote(user_id)}")
if not row:
row = safe_first("nomad_accounts", filter=f"id={pb_quote(user_id)}")
return _account_from_pb(row) if row else None
def _create_session(user_id: str) -> str:
token = secrets.token_urlsafe(32)
if use_pb():
safe_create(
"nomad_sessions",
{"token": token, "userId": user_id, "expiresAt": int(time.time()) + 86400 * 30},
)
else:
_sessions[token] = user_id
_persist_json()
return token
def _issue_login(user: dict) -> dict[str, Any]:
token = _create_session(user["id"])
return {"token": token, "user": _user_profile(user)}
def register_user(email: str, password: str, name: str) -> dict[str, Any] | None:
if use_pb():
_migrate_json_to_pb()
if _get_account_by_email(email):
return None
uid = secrets.token_hex(8)
row = safe_create(
"nomad_accounts",
{
"email": email,
"passwordHash": _hash_password(password),
"name": name,
"avatar": "🧑‍💻",
"legacyUserId": uid,
"favorites": [],
"plan": _empty_plan(),
},
)
if not row:
return None
return _issue_login(_account_from_pb(row))
if email in _users:
return None
uid = secrets.token_hex(8)
@ -27,22 +180,118 @@ def register_user(email: str, password: str, name: str) -> dict[str, Any] | None
"avatar": "🧑‍💻",
}
_favorites[uid] = []
token = secrets.token_urlsafe(32)
_sessions[token] = uid
return {"token": token, "user": _user_profile(_users[email])}
_plans[uid] = _empty_plan()
_persist_json()
return _issue_login(_users[email])
def login_user(email: str, password: str) -> dict[str, Any] | None:
if use_pb():
user = _get_account_by_email(email)
if not user or user["password_hash"] != _hash_password(password):
return None
return _issue_login(user)
user = _users.get(email)
if not user or user["password_hash"] != _hash_password(password):
return None
token = secrets.token_urlsafe(32)
_sessions[token] = user["id"]
return {"token": token, "user": _user_profile(user)}
return _issue_login(user)
def login_google(email: str, name: str) -> dict[str, Any] | None:
if use_pb():
user = _get_account_by_email(email)
if not user:
uid = secrets.token_hex(8)
row = safe_create(
"nomad_accounts",
{
"email": email,
"passwordHash": _hash_password(secrets.token_hex(16)),
"name": name or email.split("@")[0],
"avatar": "🌐",
"legacyUserId": uid,
"favorites": [],
"plan": _empty_plan(),
},
)
if not row:
return None
user = _account_from_pb(row)
return _issue_login(user)
if email not in _users:
register_user(email, secrets.token_hex(16), name or email.split("@")[0])
return _issue_login(_users[email])
def login_wechat(openid: str, unionid: str | None = None) -> dict[str, Any] | None:
"""Map WeChat openid to a stable nomadro account."""
oid = (openid or "").strip()
if not oid:
return None
# Prefer unionid for cross-app identity when present
if unionid:
email = f"wxu_{unionid}@wechat.nomadro.local"
name = f"微信用户{unionid[-4:]}"
else:
email = f"wx_{oid}@wechat.nomadro.local"
name = f"微信用户{oid[-4:]}"
if use_pb():
user = _get_account_by_email(email)
if not user:
uid = secrets.token_hex(8)
row = safe_create(
"nomad_accounts",
{
"email": email,
"passwordHash": _hash_password(secrets.token_hex(16)),
"name": name,
"avatar": "🟢",
"legacyUserId": uid,
"favorites": [],
"plan": _empty_plan(),
},
)
if not row:
return None
user = _account_from_pb(row)
return _issue_login(user)
if email not in _users:
register_user(email, secrets.token_hex(16), name)
_users[email]["avatar"] = "🟢"
return _issue_login(_users[email])
def login_demo() -> dict[str, Any] | None:
email = "demo@nomadro.com"
if use_pb():
user = _get_account_by_email(email)
if not user:
return register_user(email, "demo123", "演示用户")
return _issue_login(user)
if email not in _users:
return register_user(email, "demo123", "演示用户")
return _issue_login(_users[email])
def get_user_by_token(token: str) -> dict | None:
if use_pb():
row = safe_first("nomad_sessions", filter=f"token={pb_quote(token)}")
if not row:
return None
exp = int(row.get("expiresAt") or 0)
if exp and exp < int(time.time()):
return None
user = _get_account_by_id(row.get("userId", ""))
return _user_profile(user) if user else None
uid = _sessions.get(token)
if not uid and _restore_json():
uid = _sessions.get(token)
if not uid:
return None
for user in _users.values():
@ -51,33 +300,251 @@ def get_user_by_token(token: str) -> dict | None:
return None
def _token_user_id(token: str) -> str | None:
user = get_user_by_token(token)
return user["id"] if user else None
def get_favorites(token: str) -> list[str]:
uid = _sessions.get(token)
uid = _token_user_id(token)
if not uid:
return []
return _favorites.get(uid, [])
if use_pb():
user = _get_account_by_id(uid)
return list(user.get("favorites") or []) if user else []
return list(_favorites.get(uid, []))
def toggle_favorite(token: str, slug: str) -> list[str]:
uid = _sessions.get(token)
uid = _token_user_id(token)
if not uid:
return []
if use_pb():
user = _get_account_by_id(uid)
if not user:
return []
favs = list(user.get("favorites") or [])
if slug in favs:
favs.remove(slug)
else:
favs.append(slug)
safe_update("nomad_accounts", user["pb_id"], {"favorites": favs})
return favs
favs = _favorites.setdefault(uid, [])
if slug in favs:
favs.remove(slug)
else:
favs.append(slug)
return favs
_persist_json()
return list(favs)
def _user_profile(user: dict) -> dict:
def get_plan(token: str) -> dict[str, Any] | None:
uid = _token_user_id(token)
if not uid:
return None
if use_pb():
user = _get_account_by_id(uid)
if not user:
return None
plan = user.get("plan") or _empty_plan()
return {
"items": plan.get("items") or [],
"meta": plan.get("meta") or _empty_plan()["meta"],
"updated_at": int(plan.get("updated_at") or 0),
}
plan = _plans.get(uid) or _empty_plan()
return {
"id": user["id"],
"email": user["email"],
"name": user["name"],
"avatar": user.get("avatar", "🧑‍💻"),
"items": plan.get("items") or [],
"meta": plan.get("meta") or _empty_plan()["meta"],
"updated_at": int(plan.get("updated_at") or 0),
}
# Demo account
_demo = register_user("demo@nomadflow.io", "demo123", "演示用户")
DEMO_TOKEN = _demo["token"] if _demo else ""
def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None) -> dict[str, Any] | None:
uid = _token_user_id(token)
if not uid:
return None
ts = int(updated_at or time.time() * 1000)
payload = {"items": items, "meta": meta, "updated_at": ts}
if use_pb():
user = _get_account_by_id(uid)
if not user:
return None
safe_update("nomad_accounts", user["pb_id"], {"plan": payload})
return get_plan(token)
_plans[uid] = payload
_persist_json()
return get_plan(token)
def update_profile(token: str, *, name: str | None = None, avatar: str | None = None) -> dict | None:
uid = _token_user_id(token)
if not uid:
return None
if use_pb():
user = _get_account_by_id(uid)
if not user:
return None
patch: dict[str, Any] = {}
if name is not None and name.strip():
patch["name"] = name.strip()[:80]
if avatar is not None and avatar.strip():
patch["avatar"] = avatar.strip()[:500]
if patch:
safe_update("nomad_accounts", user["pb_id"], patch)
user = {**user, **patch}
return _user_profile(user)
for _email, user in list(_users.items()):
if user.get("id") == uid:
if name is not None and name.strip():
user["name"] = name.strip()[:80]
if avatar is not None and avatar.strip():
user["avatar"] = avatar.strip()[:500]
_persist_json()
return _user_profile(user)
return None
def ensure_user_by_email(email: str, name: str = "") -> dict[str, Any]:
"""Create or resume a session for meetup checkout flows (NomadCNA ensure-user)."""
display = (name or email.split("@")[0]).strip() or "游民"
if use_pb():
_migrate_json_to_pb()
user = _get_account_by_email(email)
is_new = False
if not user:
created = register_user(email, secrets.token_urlsafe(12), display)
if not created:
raise RuntimeError("unable to create user")
return {**created, "is_new": True}
return {**_issue_login(user), "is_new": is_new}
if email not in _users:
created = register_user(email, secrets.token_urlsafe(12), display)
if not created:
raise RuntimeError("unable to create user")
return {**created, "is_new": True}
return {**_issue_login(_users[email]), "is_new": False}
def find_user_public_by_email(email: str) -> dict | None:
if use_pb():
user = _get_account_by_email(email)
return _user_profile(user) if user else None
user = _users.get(email)
return _user_profile(user) if user else None
# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
_DEMO_SEED_USERS = [
("demo@nomadro.com", "demo123", "演示用户", "🧑‍💻"),
("xiaolin@nomadro.com", "demo123", "小林", "🧳"),
("marco@nomadro.com", "demo123", "Marco", "💻"),
("yuki@nomadro.com", "demo123", "Yuki", "🎨"),
("alex@nomadro.com", "demo123", "Alex", "🚀"),
("sara@nomadro.com", "demo123", "Sara", "☕"),
("ken@nomadro.com", "demo123", "Ken", "📊"),
("lina@nomadro.com", "demo123", "Lina", "📷"),
("devon@nomadro.com", "demo123", "Devon", "🖥️"),
("sofia@nomadro.com", "demo123", "Sofia", "🌺"),
("omar@nomadro.com", "demo123", "Omar", "🏙️"),
("nina@nomadro.com", "demo123", "Nina", "🍷"),
]
_DEMO_FAVORITES = {
"演示用户": ["chiangmai", "lisbon", "bali"],
"小林": ["chiangmai", "dali", "bali"],
"Marco": ["lisbon", "barcelona", "berlin"],
"Yuki": ["bali", "seoul", "tokyo"],
"Alex": ["mexico", "medellin", "lisbon"],
"Sara": ["barcelona", "lisbon", "medellin"],
"Ken": ["tokyo", "seoul", "berlin"],
"Lina": ["dali", "chiangmai", "bali"],
"Devon": ["berlin", "tbilisi", "lisbon"],
"Sofia": ["medellin", "mexico", "barcelona"],
"Omar": ["dubai", "lisbon", "tbilisi"],
"Nina": ["tbilisi", "berlin", "dubai"],
}
_DEMO_PLANS = {
"演示用户": {
"items": [
{"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 2, "note": "先稳住节奏"},
{"slug": "bali", "name": "巴厘岛", "country": "印尼", "emoji": "🏝️", "cost": 4500, "months": 1, "note": ""},
{"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 2, "note": "评估 D7"},
],
"meta": {"title": "演示旅居计划", "startMonth": "2026-10", "monthlyBudget": 8000, "checklist": {"visa": True, "sim": True}},
"updated_at": 1,
},
"小林": {
"items": [
{"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 3, "note": ""},
{"slug": "dali", "name": "大理", "country": "中国", "emoji": "🏔️", "cost": 4200, "months": 1, "note": "慢创作"},
],
"meta": {"title": "东南亚慢旅", "startMonth": "2026-09", "monthlyBudget": 4500, "checklist": {}},
"updated_at": 1,
},
"Marco": {
"items": [
{"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 4, "note": ""},
{"slug": "barcelona", "name": "巴塞罗那", "country": "西班牙", "emoji": "🏖️", "cost": 10500, "months": 1, "note": ""},
],
"meta": {"title": "欧洲基地", "startMonth": "2026-08", "monthlyBudget": 12000, "checklist": {"visa": True}},
"updated_at": 1,
},
"Sofia": {
"items": [
{"slug": "medellin", "name": "麦德林", "country": "哥伦比亚", "emoji": "🌺", "cost": 5200, "months": 2, "note": ""},
{"slug": "mexico", "name": "墨西哥城", "country": "墨西哥", "emoji": "🌃", "cost": 6500, "months": 1, "note": ""},
],
"meta": {"title": "拉美春城", "startMonth": "2026-10", "monthlyBudget": 5500, "checklist": {}},
"updated_at": 1,
},
"Devon": {
"items": [
{"slug": "tbilisi", "name": "第比利斯", "country": "格鲁吉亚", "emoji": "🍷", "cost": 4800, "months": 3, "note": ""},
{"slug": "berlin", "name": "柏林", "country": "德国", "emoji": "🎨", "cost": 9800, "months": 1, "note": ""},
],
"meta": {"title": "税务友好试住", "startMonth": "2026-11", "monthlyBudget": 6000, "checklist": {}},
"updated_at": 1,
},
}
def _seed_demo_users() -> None:
for email, password, name, avatar in _DEMO_SEED_USERS:
if email not in _users:
created = register_user(email, password, name)
if not created:
continue
uid = _users[email]["id"]
_users[email]["avatar"] = avatar
favs = _DEMO_FAVORITES.get(name)
if favs and not (_favorites.get(uid) or []):
_favorites[uid] = list(favs)
plan = _DEMO_PLANS.get(name)
if plan and not ((_plans.get(uid) or {}).get("items") or []):
_plans[uid] = plan
_persist_json()
# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
if not use_pb():
if not _restore_json():
login_demo()
elif "demo@nomadro.com" not in _users:
login_demo()
_seed_demo_users()
else:
_migrate_json_to_pb()
if not _get_account_by_email("demo@nomadro.com"):
register_user("demo@nomadro.com", "demo123", "演示用户")
# Best-effort: ensure a few named demo accounts exist in PocketBase too
for email, password, name, _avatar in _DEMO_SEED_USERS:
if not _get_account_by_email(email):
register_user(email, password, name)

View File

@ -0,0 +1,138 @@
"""City / destination depth aggregation (city_details + related content)."""
from __future__ import annotations
from typing import Any
from app.data.city_details_data import (
CITY_DETAILS_BY_SLUG,
CONTENT_ITEMS,
EXTRA_ROUTES,
)
from app.data import community_data
from app.services.pb_repo import q, safe_first, safe_list, use_pb
from app.services.pocketbase import pb
from app.services import community_store
def get_city_detail_record(slug: str) -> dict[str, Any] | None:
if use_pb():
row = safe_first("city_details", filter=f"citySlug={q(slug)}")
if row:
return {
"citySlug": row.get("citySlug", slug),
"guide": row.get("guide") or {},
"pros": row.get("pros") or {},
"reviews": row.get("reviews") or {},
"cost": row.get("cost") or {},
"people": row.get("people") or {},
"chat": row.get("chat") or {},
"photos": row.get("photos") or {},
"weather": row.get("weather") or {},
"trends": row.get("trends") or {},
"demographics": row.get("demographics") or {},
"relatedContent": row.get("relatedContent") or [],
}
return CITY_DETAILS_BY_SLUG.get(slug)
async def get_destination_full(slug: str) -> dict[str, Any] | None:
city = await pb.get_destination(slug)
if not city:
return None
detail = get_city_detail_record(slug)
content: list[dict] = []
if use_pb():
for item in safe_list("content_items"):
slugs = item.get("citySlugs") or []
if slug in slugs and item.get("status", "published") == "published":
content.append(_public_content(item))
if not content:
content = [_public_content(c) for c in CONTENT_ITEMS if slug in (c.get("citySlugs") or [])]
city_name = city.get("name", "")
meetups = [
m for m in community_store.list_meetups(upcoming=True)
if m.get("destination_slug") == slug or m.get("city") == city_name
][:10]
discussions = [
d for d in community_store.list_discussions()
if city_name and (city_name in d.get("title", "") or city_name in d.get("excerpt", ""))
][:10]
return {
"ok": True,
"city": city,
"detail": detail,
"content": content,
"meetups": meetups,
"discussions": discussions,
}
def _public_content(item: dict) -> dict:
return {
"slug": item.get("slug", ""),
"type": item.get("type", "guide"),
"title": item.get("title", ""),
"subtitle": item.get("subtitle") or item.get("subtitleEn") or "",
"description": item.get("description", ""),
"targetUrl": item.get("targetUrl", ""),
"ctaLabel": item.get("ctaLabel", "查看"),
"citySlugs": item.get("citySlugs") or [],
}
def list_routes_enriched(destinations: list[dict] | None = None) -> list[dict]:
dest_by_slug = {d.get("slug"): d for d in (destinations or [])}
routes: list[dict] = []
if use_pb():
for r in safe_list("routes"):
if r.get("status") and r.get("status") != "published":
continue
routes.append(_route_from_pb(r, dest_by_slug))
if not routes:
for r in community_data.NOMAD_ROUTES:
routes.append({
"id": r.get("id") or r.get("slug", ""),
"slug": r.get("slug", ""),
"title": r.get("title", ""),
"duration_days": r.get("duration_days", 0),
"budget": r.get("budget", 0),
"description": r.get("description", ""),
"stops": r.get("stops") or [],
"emoji": r.get("emoji", "🗺️"),
"cities": [dest_by_slug[s] for s in (r.get("stops") or []) if s in dest_by_slug],
})
for r in EXTRA_ROUTES:
if any(x["slug"] == r["slug"] for x in routes):
continue
routes.append({
"id": r["slug"],
"slug": r["slug"],
"title": r["title"],
"duration_days": r.get("durationDays", 0),
"budget": r.get("budget", 0),
"description": r.get("description", ""),
"stops": r.get("citySlugs") or [],
"emoji": r.get("emoji", "🗺️"),
"cities": [dest_by_slug[s] for s in (r.get("citySlugs") or []) if s in dest_by_slug],
})
return routes
def _route_from_pb(r: dict, dest_by_slug: dict) -> dict:
slugs = r.get("citySlugs") or []
return {
"id": r.get("id") or r.get("slug", ""),
"slug": r.get("slug", ""),
"title": r.get("title", ""),
"duration_days": int(r.get("durationDays") or 0),
"budget": int(r.get("budget") or 0),
"description": r.get("description", ""),
"stops": slugs,
"emoji": r.get("emoji", "🗺️"),
"cities": [dest_by_slug[s] for s in slugs if s in dest_by_slug],
}

View File

@ -0,0 +1,815 @@
"""Community data — PocketBase backed (discussions, meetups, gigs, notifications)."""
from __future__ import annotations
import re
import secrets
from datetime import datetime, timezone
from typing import Any
from app.services.pb_client import pb, pb_quote
from app.services.pb_repo import (
find_by_legacy,
pb_date,
q,
rid,
safe_create,
safe_first,
safe_list,
safe_update,
use_pb,
)
LIKE_ACTIONS = frozenset({"like", "right", "superlike"})
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _slugify(text: str) -> str:
s = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", text.lower()).strip("-")
return (s[:48] or secrets.token_hex(4))
# ── Meetups ──────────────────────────────────────────────────────────────
def _meetup_from_pb(r: dict) -> dict:
return {
"id": rid(r),
"pb_id": r["id"],
"title": r.get("title", ""),
"city": r.get("city", ""),
"destination_slug": r.get("destinationSlug", ""),
"emoji": r.get("emoji", "🎉"),
"date": pb_date(r.get("date")),
"time": r.get("time", ""),
"venue": r.get("venue", ""),
"description": r.get("description", ""),
"mode": r.get("mode", "offline"),
"access_level": r.get("accessLevel", "public"),
"mirotalkRoom": r.get("mirotalkRoom", ""),
"loungeChannel": r.get("loungeChannel", ""),
"meetingUrl": r.get("meetingUrl", ""),
"rsvp_count": int(r.get("rsvpCount") or 0),
"max_attendees": int(r.get("maxAttendees") or 30),
"organizer": r.get("organizer", ""),
"organizer_id": r.get("organizerId", ""),
"tags": r.get("tags") or [],
"is_upcoming": bool(r.get("isUpcoming", True)),
}
def _rsvp_count(meetup_pb_id: str) -> int:
rows = safe_list("meetup_rsvps", filter=f"meetupId={q(meetup_pb_id)}")
return len(rows)
def list_meetups(upcoming: bool = True) -> list[dict]:
if not use_pb():
return _json_list_meetups(upcoming)
rows = safe_list("meetups")
rows.sort(key=lambda r: r.get("created", ""), reverse=True)
items = []
for r in rows:
m = _meetup_from_pb(r)
if upcoming and not m.get("is_upcoming", True):
continue
m["rsvp_count"] = max(m["rsvp_count"], _rsvp_count(r["id"]))
items.append(m)
return items
def get_meetup(meetup_id: str) -> dict | None:
if not use_pb():
return _json_get_meetup(meetup_id)
r = find_by_legacy("meetups", meetup_id)
if not r:
return None
m = _meetup_from_pb(r)
m["rsvp_count"] = max(m["rsvp_count"], _rsvp_count(r["id"]))
return m
def create_meetup(user_id: str, user_name: str, payload: dict) -> dict:
if not use_pb():
return _json_create_meetup(user_id, user_name, payload)
mid = _slugify(payload.get("title", "meetup")) + "-" + secrets.token_hex(3)
mode = payload.get("mode", "offline")
room = payload.get("mirotalkRoom") or ""
if mode in ("online", "hybrid") and not room:
room = f"nomadro-{_slugify(payload.get('city', 'online'))}-{secrets.token_hex(2)}"
lounge = payload.get("loungeChannel") or (f"#{room}" if room else "")
date_val = payload.get("date", _now_iso())
if date_val and "T" not in str(date_val):
date_val = f"{date_val} 00:00:00.000Z"
row = safe_create(
"meetups",
{
"legacyId": mid,
"title": payload["title"],
"city": payload.get("city", "线上"),
"destinationSlug": payload.get("destination_slug", ""),
"emoji": payload.get("emoji", "🎉"),
"date": date_val,
"time": payload.get("time", "19:00"),
"venue": payload.get("venue", "待定"),
"description": payload.get("description", ""),
"mode": mode,
"accessLevel": payload.get("access_level", "public"),
"mirotalkRoom": room,
"loungeChannel": lounge,
"rsvpCount": 0,
"maxAttendees": int(payload.get("max_attendees", 30)),
"organizer": user_name,
"organizerId": user_id,
"tags": payload.get("tags", []),
"isUpcoming": True,
"status": "published",
},
)
return _meetup_from_pb(row) if row else {}
def rsvp_meetup(meetup_id: str, user_id: str | None = None) -> dict:
if not use_pb():
return _json_rsvp_meetup(meetup_id, user_id)
m = get_meetup(meetup_id)
if not m:
return {"ok": False, "error": "not_found"}
uid = user_id or f"guest-{secrets.token_hex(4)}"
pb_id = m["pb_id"]
existing = safe_first("meetup_rsvps", filter=f"meetupId={q(pb_id)} && userId={q(uid)}")
count = _rsvp_count(pb_id)
if existing:
return {"ok": True, "message": "已报名", "rsvp_count": count}
if count >= m["max_attendees"]:
return {"ok": False, "error": "full"}
safe_create("meetup_rsvps", {"meetupId": pb_id, "userId": uid, "status": "going"})
count += 1
safe_update("meetups", pb_id, {"rsvpCount": count})
return {"ok": True, "message": f"已报名「{m['title']}」", "rsvp_count": count}
def cancel_rsvp(meetup_id: str, user_id: str) -> dict:
if not use_pb():
return _json_cancel_rsvp(meetup_id, user_id)
m = get_meetup(meetup_id)
if not m:
return {"ok": False, "error": "not_found"}
rows = safe_list("meetup_rsvps", filter=f"meetupId={q(m['pb_id'])} && userId={q(user_id)}")
for r in rows:
try:
pb.delete_record("meetup_rsvps", r["id"])
except Exception:
pass
count = _rsvp_count(m["pb_id"])
safe_update("meetups", m["pb_id"], {"rsvpCount": count})
return {"ok": True, "rsvp_count": count}
def user_rsvp_ids(user_id: str) -> set[str]:
if not use_pb():
return _json_user_rsvp_ids(user_id)
rows = safe_list("meetup_rsvps", filter=f"userId={q(user_id)}")
ids: set[str] = set()
for r in rows:
meetup = safe_first("meetups", filter=f"id={q(r.get('meetupId', ''))}")
if meetup:
ids.add(rid(meetup))
return ids
# ── Discussions ────────────────────────────────────────────────────────
def _discussion_from_pb(r: dict, *, like_extra: int = 0, reply_count: int | None = None) -> dict:
base_likes = int(r.get("likes") or 0)
replies = reply_count if reply_count is not None else int(r.get("replies") or 0)
return {
"id": rid(r),
"pb_id": r["id"],
"title": r.get("title", ""),
"excerpt": r.get("excerpt", ""),
"author": r.get("author", ""),
"author_id": r.get("authorUserId", ""),
"author_emoji": r.get("authorEmoji", "🧑‍💻"),
"category": r.get("category", "社区"),
"reply_count": replies,
"like_count": base_likes + like_extra,
"view_count": int(r.get("views") or 0),
"is_pinned": bool(r.get("pinned")),
"created_at": pb_date(r.get("created")),
"tags": r.get("tags") or [],
}
def _discussion_likes_count(discussion_pb_id: str) -> int:
return len(safe_list("discussion_likes", filter=f"discussionId={q(discussion_pb_id)}"))
def list_discussions(category: str | None = None) -> list[dict]:
if not use_pb():
return _json_list_discussions(category)
rows = safe_list("discussions")
rows.sort(key=lambda r: r.get("created", ""), reverse=True)
items = []
for r in rows:
d = _discussion_from_pb(r, like_extra=_discussion_likes_count(r["id"]))
if category and d["category"] != category:
continue
items.append(d)
pinned = sorted([d for d in items if d.get("is_pinned")], key=lambda x: x["created_at"], reverse=True)
rest = sorted([d for d in items if not d.get("is_pinned")], key=lambda x: x["created_at"], reverse=True)
return pinned + rest
def get_discussion(discussion_id: str, increment_view: bool = False) -> dict | None:
if not use_pb():
return _json_get_discussion(discussion_id, increment_view)
r = find_by_legacy("discussions", discussion_id)
if not r:
return None
if increment_view:
views = int(r.get("views") or 0) + 1
safe_update("discussions", r["id"], {"views": views})
r["views"] = views
replies_raw = safe_list("discussion_replies", filter=f"discussionId={q(r['id'])}")
replies_raw.sort(key=lambda rep: rep.get("created", ""))
replies = [
{
"id": rep["id"],
"author": rep.get("author", ""),
"author_id": rep.get("userId", ""),
"author_emoji": rep.get("authorEmoji", "🧑‍💻"),
"content": rep.get("body", ""),
"created_at": pb_date(rep.get("created")),
"like_count": 0,
}
for rep in replies_raw
]
d = _discussion_from_pb(
r,
like_extra=_discussion_likes_count(r["id"]),
reply_count=len(replies),
)
return {**d, "replies": replies}
def create_discussion(user_id: str, user_name: str, payload: dict) -> dict:
if not use_pb():
return _json_create_discussion(user_id, user_name, payload)
did = _slugify(payload["title"]) + "-" + secrets.token_hex(3)
row = safe_create(
"discussions",
{
"legacyId": did,
"title": payload["title"],
"excerpt": (payload.get("excerpt") or payload.get("content") or "")[:1000],
"author": user_name,
"authorUserId": user_id,
"authorEmoji": payload.get("author_emoji", "🧑‍💻"),
"category": payload.get("category", "社区"),
"tags": payload.get("tags", []),
"replies": 0,
"views": 0,
"likes": 0,
"pinned": False,
},
)
return _discussion_from_pb(row) if row else {}
def add_reply(discussion_id: str, user_id: str, user_name: str, content: str, emoji: str = "🧑‍💻") -> dict | None:
if not use_pb():
return _json_add_reply(discussion_id, user_id, user_name, content, emoji)
r = find_by_legacy("discussions", discussion_id)
if not r:
return None
rep = safe_create(
"discussion_replies",
{
"discussionId": r["id"],
"userId": user_id,
"author": user_name,
"authorEmoji": emoji,
"body": content,
},
)
if not rep:
return None
safe_update("discussions", r["id"], {"replies": int(r.get("replies") or 0) + 1})
return {
"id": rep["id"],
"author": user_name,
"author_id": user_id,
"author_emoji": emoji,
"content": content,
"created_at": _now_iso(),
"like_count": 0,
}
def toggle_discussion_like(discussion_id: str, user_id: str) -> dict:
if not use_pb():
return _json_toggle_discussion_like(discussion_id, user_id)
r = find_by_legacy("discussions", discussion_id)
if not r:
return {"liked": False, "like_count": 0}
existing = safe_first(
"discussion_likes",
filter=f"discussionId={q(r['id'])} && userId={q(user_id)}",
)
liked = False
if existing:
try:
pb.delete_record("discussion_likes", existing["id"])
except Exception:
pass
else:
safe_create("discussion_likes", {"discussionId": r["id"], "userId": user_id})
liked = True
count = _discussion_likes_count(r["id"])
return {"liked": liked, "like_count": count}
def toggle_pin(discussion_id: str, user_id: str) -> dict | None:
if not use_pb():
return _json_toggle_pin(discussion_id, user_id)
r = find_by_legacy("discussions", discussion_id)
if not r or r.get("authorUserId") != user_id:
return None
pinned = not bool(r.get("pinned"))
updated = safe_update("discussions", r["id"], {"pinned": pinned})
return _discussion_from_pb(updated or r, like_extra=_discussion_likes_count(r["id"])) if updated or r else None
def search_discussions(qs: str) -> list[dict]:
s = qs.lower()
return [d for d in list_discussions() if s in d["title"].lower() or s in d.get("excerpt", "").lower()][:20]
# ── Gigs ───────────────────────────────────────────────────────────────
def _gig_from_pb(r: dict) -> dict:
budget = r.get("budget")
return {
"id": rid(r),
"pb_id": r["id"],
"title": r.get("title", ""),
"description": r.get("description", ""),
"budget": f"¥{budget}" if isinstance(budget, (int, float)) and budget else str(budget or "面议"),
"deadline": r.get("deadline", ""),
"tags": r.get("tags") or [],
"poster": r.get("posterName", ""),
"poster_id": r.get("posterId", ""),
"poster_email": r.get("posterEmail", ""),
"status": r.get("status", "open"),
"category": r.get("category", ""),
"location": r.get("location", ""),
}
def list_gigs() -> list[dict]:
if not use_pb():
return _json_list_gigs()
return [_gig_from_pb(r) for r in safe_list("gigs") if r.get("status", "open") == "open"]
def create_gig(user_id: str, user_name: str, payload: dict) -> dict:
if not use_pb():
return _json_create_gig(user_id, user_name, payload)
gid = _slugify(payload["title"]) + "-" + secrets.token_hex(2)
budget_raw = payload.get("budget", "面议")
budget = int(budget_raw) if str(budget_raw).isdigit() else 0
row = safe_create(
"gigs",
{
"legacyId": gid,
"title": payload["title"],
"description": payload.get("description", ""),
"budget": budget,
"location": payload.get("location", ""),
"category": payload.get("category", ""),
"posterId": user_id,
"posterName": user_name,
"status": "open",
},
)
return _gig_from_pb(row) if row else {}
def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
if not use_pb():
return _json_apply_gig(gig_id, user_id, user_name, message)
g = find_by_legacy("gigs", gig_id)
if not g:
return {"ok": False, "error": "not_found"}
dup = safe_first(
"gig_applications",
filter=f"gigId={q(g['id'])} && userId={q(user_id)}",
)
if dup:
return {"ok": False, "error": "duplicate"}
safe_create(
"gig_applications",
{"gigId": g["id"], "userId": user_id, "applicant": user_name, "message": message, "status": "pending"},
)
return {
"ok": True,
"message": "申请已提交",
"poster_id": g.get("posterId", ""),
"poster_email": g.get("posterEmail", ""),
"title": g.get("title", ""),
}
# ── Notifications ──────────────────────────────────────────────────────
def _notif_from_pb(r: dict) -> dict:
return {
"id": r["id"],
"title": r.get("title", ""),
"body": r.get("body", ""),
"link": r.get("actionUrl", ""),
"category": r.get("category", "general"),
"read": bool(r.get("read")),
"archived": bool(r.get("archived")),
"pinned": bool(r.get("pinned")),
"created_at": pb_date(r.get("created")),
}
def add_notification(user_id: str, title: str, body: str, link: str = "", category: str = "general") -> None:
if not use_pb():
return _json_add_notification(user_id, title, body, link, category)
safe_create(
"notifications",
{
"title": title,
"body": body,
"actionUrl": link,
"category": category,
"targetUserId": user_id,
"status": "published",
"read": False,
"archived": False,
"pinned": False,
},
)
def list_notifications_filtered(user_id: str, include_archived: bool = False) -> list[dict]:
if not use_pb():
return _json_list_notifications_filtered(user_id, include_archived)
filt = f"targetUserId={q(user_id)}"
if not include_archived:
filt += " && archived=false"
rows = safe_list("notifications", filter=filt)
rows.sort(key=lambda r: r.get("created", ""), reverse=True)
items = [_notif_from_pb(r) for r in rows]
pinned = [n for n in items if n.get("pinned")]
rest = [n for n in items if not n.get("pinned")]
return pinned + rest
def list_notifications(user_id: str) -> list[dict]:
return list_notifications_filtered(user_id)
def notification_unread_count(user_id: str) -> int:
if not use_pb():
return _json_notification_unread_count(user_id)
rows = safe_list(
"notifications",
filter=f"targetUserId={q(user_id)} && read=false && archived=false",
)
return len(rows)
def update_notification(user_id: str, notif_id: str, action: str) -> dict | None:
if not use_pb():
return _json_update_notification(user_id, notif_id, action)
r = safe_first("notifications", filter=f"id={q(notif_id)} && targetUserId={q(user_id)}")
if not r:
return None
patch: dict[str, Any] = {}
if action == "read":
patch["read"] = True
elif action == "unread":
patch["read"] = False
elif action == "archive":
patch["archived"] = True
elif action == "restore":
patch["archived"] = False
elif action == "pin":
patch["pinned"] = True
elif action == "unpin":
patch["pinned"] = False
updated = safe_update("notifications", r["id"], patch)
return _notif_from_pb(updated or r)
def mark_notifications_read(user_id: str) -> None:
if not use_pb():
return _json_mark_notifications_read(user_id)
for r in safe_list("notifications", filter=f"targetUserId={q(user_id)} && read=false"):
safe_update("notifications", r["id"], {"read": True})
def add_feedback(user_id: str | None, user_name: str, content: str, category: str = "general") -> dict:
if not use_pb():
return _json_add_feedback(user_id, user_name, content, category)
row = safe_create(
"feedback",
{
"type": category,
"email": "",
"title": user_name,
"content": content[:2000],
"status": "open",
},
)
return {"id": row["id"] if row else secrets.token_hex(6), "user_name": user_name, "content": content}
def meetup_social_suggestions(meetup_id: str, limit: int = 6) -> list[dict]:
m = get_meetup(meetup_id)
if not m:
return []
city = m.get("city", "")
from app.data.social_profiles import CANDIDATE_PROFILES
out = []
for p in CANDIDATE_PROFILES:
if city and city not in (p.get("location") or ""):
continue
out.append(p)
if len(out) >= limit:
break
return out[:limit] if out else CANDIDATE_PROFILES[:limit]
def stats_overview() -> dict:
if not use_pb():
return _json_stats_overview()
return {
"meetups": len(safe_list("meetups")),
"discussions": len(safe_list("discussions")),
"gigs": len(safe_list("gigs")),
"members_active": len(safe_list("meetup_rsvps")) + len(safe_list("gig_applications")),
}
# ── JSON fallback (dev without PocketBase) ───────────────────────────
import json
from copy import deepcopy
from pathlib import Path
from app.data import community_data
_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "community_store.json"
_j: dict[str, Any] = {}
_SEED_VERSION = 4
def _jload() -> None:
global _j
if _j:
return
if _JSON_PATH.exists():
try:
_j.update(json.loads(_JSON_PATH.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError):
pass
needs_seed = not _j.get("seeded") or not (
(_j.get("meetups") or []) and (_j.get("discussions") or []) and (_j.get("gigs") or [])
)
version_bump = int(_j.get("seed_version") or 0) < _SEED_VERSION
if needs_seed or version_bump:
keep_notif = _j.get("notifications") or {}
keep_rsvp = _j.get("rsvps") or {}
keep_apps = _j.get("gig_apps") or []
keep_feedback = _j.get("feedback") or []
keep_views = _j.get("views") or {}
keep_likes = _j.get("discussion_likes") or {}
_j.update({
"meetups": deepcopy(community_data.MEETUPS),
"discussions": deepcopy(community_data.DISCUSSIONS),
"replies": deepcopy(community_data.DISCUSSION_REPLIES),
"gigs": deepcopy(getattr(community_data, "GIGS", [])),
"discussion_likes": keep_likes,
"rsvps": keep_rsvp,
"gig_apps": keep_apps,
"notifications": keep_notif,
"feedback": keep_feedback,
"views": keep_views,
"seeded": True,
"seed_version": _SEED_VERSION,
})
_jsave()
def _jsave() -> None:
_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
_JSON_PATH.write_text(json.dumps(_j, ensure_ascii=False), encoding="utf-8")
def _json_list_meetups(upcoming: bool) -> list[dict]:
_jload()
items = list(_j.get("meetups") or [])
if upcoming:
items = [m for m in items if m.get("is_upcoming", True)]
return items
def _json_get_meetup(mid: str) -> dict | None:
_jload()
return next((m for m in _j.get("meetups") or [] if m["id"] == mid), None)
def _json_create_meetup(user_id: str, user_name: str, payload: dict) -> dict:
_jload()
mid = _slugify(payload.get("title", "meetup")) + "-" + secrets.token_hex(3)
meetup = {"id": mid, "title": payload["title"], "city": payload.get("city", "线上"), "organizer": user_name, "organizer_id": user_id, "is_upcoming": True, "rsvp_count": 0, "max_attendees": 30, **payload}
_j.setdefault("meetups", []).insert(0, meetup)
_jsave()
return meetup
def _json_rsvp_meetup(meetup_id: str, user_id: str | None) -> dict:
_jload()
m = _json_get_meetup(meetup_id)
if not m:
return {"ok": False, "error": "not_found"}
uid = user_id or f"guest-{secrets.token_hex(4)}"
rsvps = _j.setdefault("rsvps", {})
rset = set(rsvps.get(meetup_id, []))
if uid in rset:
return {"ok": True, "message": "已报名", "rsvp_count": len(rset)}
rset.add(uid)
rsvps[meetup_id] = list(rset)
_jsave()
return {"ok": True, "message": "已报名", "rsvp_count": len(rset)}
def _json_cancel_rsvp(meetup_id: str, user_id: str) -> dict:
_jload()
rsvps = _j.setdefault("rsvps", {})
rset = set(rsvps.get(meetup_id, []))
rset.discard(user_id)
rsvps[meetup_id] = list(rset)
_jsave()
return {"ok": True, "rsvp_count": len(rset)}
def _json_user_rsvp_ids(user_id: str) -> set[str]:
_jload()
return {mid for mid, uids in (_j.get("rsvps") or {}).items() if user_id in uids}
def _json_list_discussions(category: str | None) -> list[dict]:
_jload()
items = list(_j.get("discussions") or [])
if category:
items = [d for d in items if d.get("category") == category]
return items
def _json_get_discussion(did: str, inc: bool) -> dict | None:
_jload()
for d in _j.get("discussions") or []:
if d["id"] == did:
if inc:
views = _j.setdefault("views", {})
views[did] = views.get(did, 0) + 1
_jsave()
replies = _j.get("replies", {}).get(did, [])
return {**d, "replies": replies}
return None
def _json_create_discussion(user_id: str, user_name: str, payload: dict) -> dict:
_jload()
did = _slugify(payload["title"]) + "-" + secrets.token_hex(3)
d = {"id": did, "title": payload["title"], "author": user_name, "author_id": user_id, "created_at": _now_iso(), **payload}
_j.setdefault("discussions", []).insert(0, d)
_j.setdefault("replies", {})[did] = []
_jsave()
return d
def _json_add_reply(did: str, user_id: str, user_name: str, content: str, emoji: str) -> dict | None:
_jload()
if not any(d["id"] == did for d in _j.get("discussions") or []):
return None
reply = {"id": secrets.token_hex(6), "author": user_name, "author_id": user_id, "content": content, "created_at": _now_iso()}
_j.setdefault("replies", {}).setdefault(did, []).append(reply)
_jsave()
return reply
def _json_toggle_discussion_like(did: str, user_id: str) -> dict:
_jload()
likes = _j.setdefault("discussion_likes", {}).setdefault(did, [])
if user_id in likes:
likes.remove(user_id)
liked = False
else:
likes.append(user_id)
liked = True
_jsave()
return {"liked": liked, "like_count": len(likes)}
def _json_toggle_pin(did: str, user_id: str) -> dict | None:
_jload()
for d in _j.get("discussions") or []:
if d["id"] == did and d.get("author_id") == user_id:
d["is_pinned"] = not d.get("is_pinned", False)
_jsave()
return d
return None
def _json_list_gigs() -> list[dict]:
_jload()
return [g for g in _j.get("gigs") or [] if g.get("status", "open") == "open"]
def _json_create_gig(user_id: str, user_name: str, payload: dict) -> dict:
_jload()
gid = _slugify(payload["title"]) + "-" + secrets.token_hex(2)
gig = {"id": gid, "poster": user_name, "poster_id": user_id, "status": "open", **payload}
_j.setdefault("gigs", []).insert(0, gig)
_jsave()
return gig
def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
_jload()
gig = next((g for g in (_j.get("gigs") or []) if g.get("id") == gig_id), {}) or {}
_j.setdefault("gig_apps", []).append({"gig_id": gig_id, "user_id": user_id, "user_name": user_name, "message": message})
_jsave()
return {
"ok": True,
"message": "申请已提交",
"poster_id": gig.get("poster_id", ""),
"poster_email": gig.get("poster_email", ""),
"title": gig.get("title", ""),
}
def _json_add_notification(user_id: str, title: str, body: str, link: str, category: str) -> None:
_jload()
items = _j.setdefault("notifications", {}).setdefault(user_id, [])
items.insert(0, {"id": secrets.token_hex(6), "title": title, "body": body, "link": link, "category": category, "read": False, "archived": False, "pinned": False, "created_at": _now_iso()})
_jsave()
def _json_list_notifications_filtered(user_id: str, include_archived: bool) -> list[dict]:
_jload()
items = _j.get("notifications", {}).get(user_id, [])
if not include_archived:
items = [n for n in items if not n.get("archived")]
return items
def _json_notification_unread_count(user_id: str) -> int:
return sum(1 for n in _json_list_notifications_filtered(user_id, False) if not n.get("read"))
def _json_update_notification(user_id: str, notif_id: str, action: str) -> dict | None:
_jload()
for n in _j.get("notifications", {}).get(user_id, []):
if n["id"] == notif_id:
if action == "read":
n["read"] = True
_jsave()
return n
return None
def _json_mark_notifications_read(user_id: str) -> None:
_jload()
for n in _j.get("notifications", {}).get(user_id, []):
n["read"] = True
_jsave()
def _json_add_feedback(user_id: str | None, user_name: str, content: str, category: str) -> dict:
_jload()
item = {"id": secrets.token_hex(6), "user_name": user_name, "content": content, "category": category}
_j.setdefault("feedback", []).append(item)
_jsave()
return item
def _json_stats_overview() -> dict:
_jload()
return {
"meetups": len(_j.get("meetups") or []),
"discussions": len(_j.get("discussions") or []),
"gigs": len(_j.get("gigs") or []),
"members_active": len(_j.get("rsvps") or {}),
}

View File

@ -0,0 +1,68 @@
"""Meetup live room URL builders — ported from NomadCNA."""
from __future__ import annotations
import os
import re
from urllib.parse import urlencode
MIROTALK_URL = os.getenv("MIROTALK_URL", "https://mirotalk.nomadro.com")
LOUNGE_URL = os.getenv("LOUNGE_URL", "https://lounge.nomadro.com")
VIP_ROLES = frozenset({"admin", "owner", "host", "organizer", "moderator"})
def normalize_room(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-")
return slug[:64] or "nomadro-live"
def create_mirotalk_room_slug(meetup: dict) -> str:
city = normalize_room(meetup.get("city") or "online")
date = (meetup.get("date") or "").replace("-", "")[:8]
time_part = (meetup.get("time") or "0000").replace(":", "")[:4]
mid = normalize_room(meetup.get("id") or "room")
return f"nomadro-{city}-{date}-{time_part}-{mid}"
def create_lounge_channel(meetup: dict) -> str:
if meetup.get("loungeChannel"):
ch = meetup["loungeChannel"].strip()
return ch if ch.startswith("#") else f"#{ch}"
if meetup.get("mirotalkRoom"):
return f"#{normalize_room(meetup['mirotalkRoom'])}"
return f"#{create_mirotalk_room_slug(meetup)}"
def build_mirotalk_join_url(meetup: dict, display_name: str = "nomadro") -> str:
explicit = (meetup.get("meetingUrl") or "").strip()
if explicit.startswith("http"):
return explicit
room = meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup)
return f"{MIROTALK_URL.rstrip('/')}/join?{urlencode({'room': room, 'name': display_name})}"
def build_lounge_chat_url(meetup: dict) -> str:
channel = create_lounge_channel(meetup).lstrip("#")
return f"{LOUNGE_URL.rstrip('/')}/#/chan-{channel}"
def can_access_meetup(meetup: dict, user: dict | None, is_vip: bool = False) -> tuple[bool, str]:
level = meetup.get("access_level") or "public"
if level == "public":
return True, ""
if not user:
return False, "login_required"
if level == "members":
return True, ""
if level == "vip":
role = (user.get("role") or "").lower()
if is_vip or role in VIP_ROLES:
return True, ""
return False, "vip_required"
if level == "hosts":
role = (user.get("role") or "").lower()
if role in VIP_ROLES:
return True, ""
return False, "host_required"
return True, ""

View File

@ -0,0 +1,58 @@
"""Unified in-app + optional ntfy push notifications."""
from __future__ import annotations
import asyncio
import re
from app.config import settings
from app.services import community_store, platform_store
from app.services.ntfy_push import send_push
_PREF_KEY = {
"match": "match",
"meetup": "meetup",
"community": "community",
"gig": "community",
"general": None,
}
def _topic_for(user_id: str) -> str:
safe = re.sub(r"[^a-zA-Z0-9_-]", "", user_id)[:48] or "guest"
return f"nomadro-{safe}"
def notify_user(
user_id: str,
title: str,
body: str,
link: str = "",
category: str = "general",
) -> bool:
"""Persist inbox notification; optionally fire ntfy when push prefs allow."""
if not user_id:
return False
prefs = platform_store.get_notif_prefs(user_id)
pref_key = _PREF_KEY.get(category)
if pref_key is not None and not prefs.get(pref_key, True):
return False
community_store.add_notification(user_id, title, body, link, category)
if prefs.get("push") and settings.ntfy_enabled:
click = link if link.startswith("http") else f"{settings.site_base_url.rstrip('/')}{link or '/notifications'}"
try:
loop = asyncio.get_running_loop()
loop.create_task(
send_push(
_topic_for(user_id),
title,
body,
tags=["nomadro", category],
click_url=click,
)
)
except RuntimeError:
# Outside async context — best-effort sync skip; inbox already saved
pass
return True

View File

@ -0,0 +1,31 @@
"""ntfy push notifications (server: 127.0.0.1:2586)."""
from __future__ import annotations
import httpx
from app.config import settings
async def send_push(
topic: str,
title: str,
body: str,
*,
tags: list[str] | None = None,
click_url: str = "",
) -> bool:
if not settings.ntfy_enabled:
return False
base = settings.ntfy_url.rstrip("/")
url = f"{base}/{topic}"
headers: dict[str, str] = {"Title": title}
if click_url:
headers["Click"] = click_url
if tags:
headers["Tags"] = ",".join(tags)
try:
async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.post(url, content=body, headers=headers)
return r.status_code < 300
except Exception:
return False

View File

@ -0,0 +1,241 @@
"""ZPay / XorPay providers — ported from NomadCNA backend/payment.py."""
from __future__ import annotations
import hashlib
import json
import os
from typing import Any
import httpx
def _md5(value: str) -> str:
return hashlib.md5(value.encode("utf-8")).hexdigest().lower()
def _is_internal_ip(ip: str | None) -> bool:
if not ip:
return True
value = ip.strip().lower()
if value in {"127.0.0.1", "localhost", "::1"}:
return True
if value.startswith("10.") or value.startswith("192.168."):
return True
if value.startswith("172."):
parts = value.split(".")
if len(parts) > 1 and parts[1].isdigit():
return 16 <= int(parts[1]) <= 31
return False
def resolve_channel(channel: str | None) -> str:
return "wxpay" if str(channel or "").lower() in {"", "wx", "wechat", "wxpay"} else "alipay"
class ZPayProvider:
name = "zpay"
def __init__(self) -> None:
self.pid = (os.getenv("ZPAY_PID") or "").strip()
self.key = (os.getenv("ZPAY_KEY") or "").strip()
if not self.pid or not self.key:
raise RuntimeError("ZPAY_PID / ZPAY_KEY 未配置,无法发起支付")
self.submit_url = os.getenv("ZPAY_SUBMIT_URL", "https://zpayz.cn/submit.php")
self.mapi_url = os.getenv("ZPAY_MAPI_URL", "https://zpayz.cn/mapi.php")
self.query_url = os.getenv("ZPAY_QUERY_URL", "https://zpayz.cn/api.php")
def sign(self, params: dict[str, Any]) -> str:
filtered = {
key: value
for key, value in params.items()
if key not in {"sign", "sign_type"} and value is not None and str(value) != ""
}
raw = "&".join(f"{key}={filtered[key]}" for key in sorted(filtered)) + self.key
return _md5(raw)
def create_order(
self,
*,
order_id: str,
name: str,
amount_yuan: str,
channel: str,
notify_url: str,
return_url: str,
client_ip: str = "",
) -> dict[str, Any]:
params: dict[str, Any] = {
"pid": self.pid,
"type": resolve_channel(channel),
"out_trade_no": order_id,
"notify_url": notify_url,
"return_url": return_url,
"name": name,
"money": amount_yuan,
"sign_type": "MD5",
}
if client_ip and not _is_internal_ip(client_ip):
params["clientip"] = client_ip
params["sign"] = self.sign(params)
return {
"status": "ok",
"provider": self.name,
"params": params,
"pay_url": self.submit_url,
"submit_method": "POST",
}
async def create_order_api(
self,
*,
order_id: str,
name: str,
amount_yuan: str,
channel: str,
notify_url: str,
return_url: str,
client_ip: str = "",
device: str = "pc",
) -> dict[str, Any]:
params: dict[str, Any] = {
"pid": self.pid,
"type": resolve_channel(channel),
"out_trade_no": order_id,
"notify_url": notify_url,
"return_url": return_url,
"name": name,
"money": amount_yuan,
"device": device,
"sign_type": "MD5",
}
if client_ip and not _is_internal_ip(client_ip):
params["clientip"] = client_ip
params["sign"] = self.sign(params)
try:
async with httpx.AsyncClient(timeout=15.0, trust_env=False) as client:
res = await client.post(self.mapi_url, data=params)
except Exception as exc:
return {"status": "error", "provider": self.name, "msg": str(exc)}
result: Any
try:
result = res.json()
except Exception:
result = {}
if isinstance(result, str):
try:
result = json.loads(result)
except Exception:
result = {}
if not isinstance(result, dict):
result = {}
msg = result.get("msg")
if isinstance(msg, str) and msg.strip().startswith("{"):
try:
inner = json.loads(msg)
if isinstance(inner, dict):
result = inner
except Exception:
pass
try:
code = int(float(result.get("code", 0)))
except (TypeError, ValueError):
code = 0
if code == 1:
return {
"status": "ok",
"provider": self.name,
"payurl": result.get("payurl"),
"payurl2": result.get("payurl2"),
"qrcode": result.get("qrcode"),
"img": result.get("img"),
"trade_no": result.get("trade_no"),
}
return {"status": "error", "provider": self.name, "msg": result.get("msg") or res.text[:500]}
def verify_notify(self, data: dict[str, Any]) -> bool:
return self.sign(data) == str(data.get("sign", ""))
async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]:
try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
res = await client.get(
self.query_url,
params={"act": "order", "pid": self.pid, "key": self.key, "out_trade_no": order_id},
)
data = res.json() if res.is_success else {}
except Exception as exc:
return {"paid": False, "error": str(exc)}
try:
code = int(float(data.get("code", 0)))
status = int(float(data.get("status", 0)))
except (TypeError, ValueError):
return {"paid": False, "msg": data.get("msg", "查询失败")}
pay_price = data.get("money") or data.get("pay_price") or ""
return {"paid": code == 1 and status == 1, "status": status, "pay_price": str(pay_price)}
class XorPayProvider:
name = "xorpay"
def __init__(self) -> None:
self.aid = (os.getenv("XORPAY_AID") or "").strip()
self.secret = (os.getenv("XORPAY_SECRET") or "").strip()
if not self.aid or not self.secret:
raise RuntimeError("XORPAY_AID / XORPAY_SECRET 未配置,无法发起支付")
self.cashier_url = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier").rstrip("/")
self.query_url = os.getenv("XORPAY_QUERY_URL", "https://xorpay.com/api/query2").rstrip("/")
def create_order(self, *, order_id: str, name: str, amount_yuan: str, notify_url: str) -> dict[str, Any]:
params = {
"name": name,
"pay_type": "jsapi",
"price": amount_yuan,
"order_id": order_id,
"notify_url": notify_url,
}
params["sign"] = _md5(
params["name"] + params["pay_type"] + params["price"] + params["order_id"] + params["notify_url"] + self.secret
)
return {"status": "ok", "provider": self.name, "params": params, "pay_url": f"{self.cashier_url}/{self.aid}"}
def verify_notify(self, data: dict[str, Any]) -> bool:
raw = (
str(data.get("aoid", ""))
+ str(data.get("order_id", ""))
+ str(data.get("pay_price", ""))
+ str(data.get("pay_time", ""))
+ self.secret
)
return _md5(raw) == str(data.get("sign", ""))
async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]:
sign = _md5(order_id + self.secret)
try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
res = await client.get(f"{self.query_url}/{self.aid}", params={"order_id": order_id, "sign": sign})
data = res.json() if res.is_success else {}
status = str(data.get("status", "")).lower()
return {
"paid": status in {"payed", "success"},
"status": status,
"pay_price": data.get("pay_price") or data.get("price") or "",
}
except Exception as exc:
return {"paid": False, "error": str(exc)}
def provider_for(device: str = "pc", requested: str = "", user_agent: str = "") -> ZPayProvider | XorPayProvider:
req = requested.strip().lower()
default = os.getenv("PAYMENT_PROVIDER", "zpay").lower()
if req == "xorpay":
return XorPayProvider()
if req == "zpay":
return ZPayProvider()
if default == "xorpay":
return XorPayProvider()
if device.lower() == "wechat" or "micromessenger" in user_agent.lower():
return XorPayProvider()
return ZPayProvider()

View File

@ -0,0 +1,119 @@
"""Payment helpers — ZPay/XorPay from NomadCNA + DEV_AUTO_PAY toggle."""
from __future__ import annotations
import os
from urllib.parse import urlencode
from app.services.payment_providers import XorPayProvider, ZPayProvider, provider_for
DEV_AUTO_PAY = os.getenv("DEV_AUTO_PAY", "false").lower() in ("1", "true", "yes")
PAYMENT_JOIN_AMOUNT = int(os.getenv("PAYMENT_JOIN_AMOUNT", "10000")) # fen (100 yuan)
PAYMENT_DEFAULT_AMOUNT = int(os.getenv("PAYMENT_DEFAULT_AMOUNT", "10000"))
PAYMENT_EBOOK_AMOUNT = int(os.getenv("PAYMENT_EBOOK_AMOUNT", "2900")) # fen (29 yuan)
SITE_BASE = os.getenv("SITE_BASE_URL", "https://nomadweb.nomadro.com").rstrip("/")
PAY_NOTIFY_URL = os.getenv("PAY_NOTIFY_URL", f"{SITE_BASE}/api/v1/pay/zpay_notify")
EBOOK_PDF_URL = os.getenv("EBOOK_PDF_URL", "")
EBOOK_EPUB_URL = os.getenv("EBOOK_EPUB_URL", "")
def amount_yuan(amount_fen: int) -> str:
return f"{amount_fen / 100:.2f}"
def notify_url_for(provider_name: str) -> str:
if provider_name == "xorpay":
return os.getenv("XORPAY_NOTIFY_URL", f"{SITE_BASE}/api/v1/pay/xorpay_notify")
return os.getenv("ZPAY_NOTIFY_URL", PAY_NOTIFY_URL)
async def create_pay_redirect(
order_id: str,
amount: int,
return_url: str,
provider: str = "",
*,
channel: str = "alipay",
device: str = "pc",
user_agent: str = "",
client_ip: str = "",
product_name: str = "nomadro会员",
) -> str:
if DEV_AUTO_PAY:
sep = "&" if "?" in return_url else "?"
return f"{return_url}{sep}order_id={order_id}&status=paid"
try:
pay_provider = provider_for(device, provider or os.getenv("PAYMENT_PROVIDER", "zpay"), user_agent)
except RuntimeError as exc:
raise ValueError(str(exc)) from exc
yuan = amount_yuan(amount)
name = product_name
notify = notify_url_for(pay_provider.name)
if isinstance(pay_provider, XorPayProvider):
created = pay_provider.create_order(
order_id=order_id, name=name, amount_yuan=yuan, notify_url=notify,
)
return f"{created['pay_url']}?{urlencode(created['params'])}"
api_result = await pay_provider.create_order_api(
order_id=order_id,
name=name,
amount_yuan=yuan,
channel=channel,
notify_url=notify,
return_url=return_url,
client_ip=client_ip,
device=device,
)
if api_result.get("status") == "ok":
pay_url = api_result.get("payurl2") if device == "h5" and api_result.get("payurl2") else api_result.get("payurl")
if pay_url:
return str(pay_url)
created = pay_provider.create_order(
order_id=order_id,
name=name,
amount_yuan=yuan,
channel=channel,
notify_url=notify,
return_url=return_url,
client_ip=client_ip,
)
return f"{created['pay_url']}?{urlencode(created['params'])}"
async def query_order_paid(order_id: str) -> bool:
if DEV_AUTO_PAY:
return True
paid = False
try:
zpay = await ZPayProvider().query_order_status(order_id)
paid = bool(zpay.get("paid"))
except RuntimeError:
pass
if paid:
return True
try:
xorpay = await XorPayProvider().query_order_status(order_id)
return bool(xorpay.get("paid"))
except RuntimeError:
return False
def verify_notify(provider: str, data: dict) -> bool:
if DEV_AUTO_PAY:
return True
try:
if provider == "xorpay":
return XorPayProvider().verify_notify(data)
return ZPayProvider().verify_notify(data)
except RuntimeError:
return False
def parse_notify_order_id(provider: str, data: dict) -> str:
if provider == "xorpay":
return str(data.get("order_id") or "")
return str(data.get("out_trade_no") or data.get("order_id") or "")

View File

@ -0,0 +1,165 @@
"""PocketBase admin client — full CRUD for FastAPI services."""
from __future__ import annotations
from typing import Any
from urllib.parse import quote
import httpx
from app.config import settings
class PocketBaseError(RuntimeError):
pass
def pb_quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
class PocketBaseClient:
def __init__(self) -> None:
self._admin_token: str | None = None
self._client: httpx.Client | None = None
@property
def base_url(self) -> str:
return settings.pocketbase_url.rstrip("/")
def _http(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client(timeout=20.0, trust_env=False)
return self._client
def request(
self,
method: str,
path: str,
*,
token: str | None = None,
admin: bool = False,
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
headers: dict[str, str] = {}
if admin:
token = self.admin_token()
if token:
headers["Authorization"] = token if token.startswith("Bearer ") else token
url = f"{self.base_url}{path}"
res = self._http().request(method, url, headers=headers, json=json, params=params)
if res.status_code >= 400:
try:
detail = res.json()
except Exception:
detail = res.text
raise PocketBaseError(f"PocketBase {res.status_code}: {detail}")
if not res.content:
return None
return res.json()
def admin_token(self) -> str:
if self._admin_token:
return self._admin_token
body = {
"identity": settings.pocketbase_admin_email,
"password": settings.pocketbase_admin_password,
}
last_error: Exception | None = None
for endpoint in (
"/api/collections/_superusers/auth-with-password",
"/api/admins/auth-with-password",
):
try:
data = self.request("POST", endpoint, json=body)
token = (data or {}).get("token")
if token:
self._admin_token = token
return token
except Exception as exc:
last_error = exc
raise PocketBaseError(f"Unable to authenticate PocketBase admin: {last_error}")
def list_records(
self,
collection: str,
*,
filter: str | None = None,
sort: str | None = None,
page: int = 1,
per_page: int = 200,
admin: bool = True,
) -> dict[str, Any]:
params: dict[str, Any] = {"page": page, "perPage": per_page}
if filter:
params["filter"] = filter
if sort:
params["sort"] = sort
return self.request(
"GET",
f"/api/collections/{collection}/records",
admin=admin,
params=params,
)
def list_all(self, collection: str, *, filter: str | None = None, sort: str | None = None) -> list[dict[str, Any]]:
page = 1
items: list[dict[str, Any]] = []
while True:
data = self.list_records(collection, filter=filter, sort=sort, page=page)
batch = data.get("items") or []
items.extend(batch)
if page >= (data.get("totalPages") or 1):
break
page += 1
return items
def first_record(self, collection: str, *, filter: str, admin: bool = True) -> dict[str, Any] | None:
data = self.list_records(collection, filter=filter, per_page=1, admin=admin)
items = data.get("items") or []
return items[0] if items else None
def create_record(self, collection: str, payload: dict[str, Any]) -> dict[str, Any]:
return self.request(
"POST",
f"/api/collections/{collection}/records",
admin=True,
json=payload,
)
def update_record(self, collection: str, record_id: str, payload: dict[str, Any]) -> dict[str, Any]:
return self.request(
"PATCH",
f"/api/collections/{collection}/records/{record_id}",
admin=True,
json=payload,
)
def delete_record(self, collection: str, record_id: str) -> None:
self.request(
"DELETE",
f"/api/collections/{collection}/records/{record_id}",
admin=True,
)
def upsert_by_field(
self,
collection: str,
field: str,
value: str,
payload: dict[str, Any],
) -> dict[str, Any]:
existing = self.first_record(collection, filter=f"{field}={pb_quote(value)}")
if existing:
return self.update_record(collection, existing["id"], payload)
return self.create_record(collection, payload)
def health_ok(self) -> bool:
try:
res = self._http().get(f"{self.base_url}/api/health", timeout=2.0)
return res.status_code == 200
except Exception:
return False
pb = PocketBaseClient()

View File

@ -0,0 +1,80 @@
"""Shared PocketBase helpers for store modules."""
from __future__ import annotations
from typing import Any
from app.services.pb_client import PocketBaseError, pb, pb_quote
_pb_ready: bool | None = None
def use_pb() -> bool:
global _pb_ready
if _pb_ready is not None:
return _pb_ready
try:
if not pb.health_ok():
_pb_ready = False
return False
pb.admin_token()
_pb_ready = True
except Exception:
_pb_ready = False
return _pb_ready
def reset_pb_cache() -> None:
global _pb_ready
_pb_ready = None
def q(value: str) -> str:
return pb_quote(value)
def rid(record: dict[str, Any], legacy_field: str = "legacyId") -> str:
return str(record.get(legacy_field) or record.get("id") or "")
def pb_date(value: Any) -> str:
if not value:
return ""
s = str(value)
return s[:10] if len(s) >= 10 else s
def safe_list(collection: str, **kwargs: Any) -> list[dict[str, Any]]:
try:
return pb.list_all(collection, **kwargs)
except PocketBaseError:
return []
def safe_first(collection: str, *, filter: str) -> dict[str, Any] | None:
try:
return pb.first_record(collection, filter=filter)
except PocketBaseError:
return None
def safe_create(collection: str, payload: dict[str, Any]) -> dict[str, Any] | None:
try:
return pb.create_record(collection, payload)
except PocketBaseError:
return None
def safe_update(collection: str, record_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
try:
return pb.update_record(collection, record_id, payload)
except PocketBaseError:
return None
def find_by_legacy(collection: str, legacy_id: str) -> dict[str, Any] | None:
if not legacy_id:
return None
row = safe_first(collection, filter=f"legacyId={q(legacy_id)}")
if row:
return row
return safe_first(collection, filter=f"id={q(legacy_id)}")

View File

@ -0,0 +1,204 @@
"""Platform persistence — PocketBase backed."""
from __future__ import annotations
import json
import secrets
from datetime import datetime, timezone
from pathlib import Path
from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb
from app.config import settings
_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json"
_j: dict = {}
def _now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _jload() -> dict:
global _j
if _j:
return _j
if _JSON_PATH.exists():
try:
_j = json.loads(_JSON_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
_j = {}
else:
_j = {}
return _j
def _jsave() -> None:
_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
_JSON_PATH.write_text(json.dumps(_j, ensure_ascii=False), encoding="utf-8")
def add_service_lead(service_id: str, user_id: str | None, name: str, email: str, message: str) -> dict:
item = {
"serviceSlug": service_id,
"userId": user_id or "",
"email": email,
"note": f"{name}: {message}"[:1000],
"status": "open",
}
if use_pb():
row = safe_create("service_leads", item)
return {"id": row["id"] if row else secrets.token_hex(6), **item}
_jload()
out = {"id": secrets.token_hex(6), "service_id": service_id, "name": name, "email": email, "message": message, "created_at": _now()}
_j.setdefault("leads", []).append(out)
_jsave()
return out
def add_content_submission(user_id: str | None, name: str, title: str, content_type: str, url: str, notes: str) -> dict:
if use_pb():
row = safe_create(
"content_submissions",
{
"title": title,
"type": content_type,
"mediaUrl": url,
"submittedBy": user_id or "",
"authorName": name,
"description": notes[:2000],
"reviewStatus": "pending",
},
)
return {"id": row["id"] if row else secrets.token_hex(6), "title": title, "status": "pending"}
_jload()
out = {"id": secrets.token_hex(6), "title": title, "content_type": content_type, "url": url, "status": "pending", "created_at": _now()}
_j.setdefault("submissions", []).append(out)
_jsave()
return out
def add_volunteer(city_slug: str, user_id: str | None, name: str, email: str, message: str) -> dict:
if use_pb():
row = safe_create(
"volunteer_applications",
{
"citySlug": city_slug,
"userId": user_id or "",
"applicantName": name,
"email": email,
"motivation": message[:2000],
"status": "pending",
},
)
return {"id": row["id"] if row else secrets.token_hex(6), "city_slug": city_slug}
_jload()
out = {"id": secrets.token_hex(6), "city_slug": city_slug, "name": name, "email": email, "created_at": _now()}
_j.setdefault("volunteers", []).append(out)
_jsave()
return out
def add_report(reporter_id: str | None, target_type: str, target_id: str, reason: str) -> dict:
if use_pb():
row = safe_create(
"reports",
{
"reporterId": reporter_id or "",
"targetType": target_type,
"targetId": target_id,
"reason": reason[:2000],
"status": "open",
},
)
return {"id": row["id"] if row else secrets.token_hex(6), "status": "open"}
_jload()
out = {"id": secrets.token_hex(6), "target_type": target_type, "target_id": target_id, "status": "open", "created_at": _now()}
_j.setdefault("reports", []).append(out)
_jsave()
return out
def subscribe_newsletter(email: str, source: str = "site") -> dict:
"""Save subscription locally; sync to Listmonk when configured."""
email = email.strip().lower()
synced = False
if settings.listmonk_url and settings.listmonk_user:
try:
import httpx
base = settings.listmonk_url.rstrip("/")
auth = (settings.listmonk_user, settings.listmonk_password)
payload = {
"email": email,
"name": email.split("@")[0],
"status": "enabled",
"lists": [settings.listmonk_list_id],
}
with httpx.Client(timeout=8.0) as client:
r = client.post(f"{base}/api/subscribers", json=payload, auth=auth)
if r.status_code in (200, 409):
synced = True
elif r.status_code == 400 and "exists" in (r.text or "").lower():
synced = True
except Exception:
synced = False
if use_pb():
dup = safe_first("subscriptions", filter=f"email={q(email)}")
if dup:
return {
"ok": True,
"message": "已订阅" if synced else "已登记订阅(邮件系统稍后开通)",
"synced": synced,
}
safe_create("subscriptions", {"email": email})
return {
"ok": True,
"message": "订阅成功" if synced else "已登记,邮件推送开通后会同步发送",
"synced": synced,
}
_jload()
for n in _j.get("newsletter") or []:
if n["email"] == email:
return {"ok": True, "message": "已订阅", "synced": synced}
_j.setdefault("newsletter", []).append({"email": email, "source": source, "created_at": _now()})
_jsave()
return {
"ok": True,
"message": "订阅成功" if synced else "已登记,邮件推送开通后会同步发送",
"synced": synced,
}
def get_notif_prefs(user_id: str) -> dict:
default = {
"email": True,
"push": False,
"match": True,
"meetup": True,
"community": True,
"marketing": False,
"quiet_hours": "",
}
if use_pb():
row = safe_first("notification_preferences", filter=f"userId={q(user_id)}")
if not row:
return default
channels = row.get("channels") or {}
return {**default, **channels}
_jload()
return _j.get("notif_prefs", {}).get(user_id) or default
def set_notif_prefs(user_id: str, prefs: dict) -> dict:
merged = {**get_notif_prefs(user_id), **prefs}
if use_pb():
row = safe_first("notification_preferences", filter=f"userId={q(user_id)}")
if row:
safe_update("notification_preferences", row["id"], {"channels": merged, "userId": user_id})
else:
safe_create("notification_preferences", {"userId": user_id, "channels": merged})
return merged
_jload()
_j.setdefault("notif_prefs", {})[user_id] = merged
_jsave()
return merged

View File

@ -1,4 +1,4 @@
import httpx
import httpx
from typing import Any
from app.config import settings
@ -26,15 +26,9 @@ class PocketBaseService:
if self._token:
return True
try:
r = await client.post(
f"{self.base_url}/api/admins/auth-with-password",
json={
"identity": settings.pocketbase_admin_email,
"password": settings.pocketbase_admin_password,
},
)
if r.status_code == 200:
self._token = r.json().get("token")
from app.services.pb_client import pb as sync_pb
if sync_pb.health_ok():
self._token = sync_pb.admin_token()
return True
except Exception:
pass
@ -145,9 +139,8 @@ class PocketBaseService:
result = await self.create_record("subscriptions", {"email": email})
if result:
return True, "订阅成功!欢迎加入 NomadFlow 社区 🎉"
# fallback: always succeed in demo mode
return True, "订阅成功!欢迎加入 NomadFlow 社区 🎉"
return True, "已登记订阅。邮件推送开通后会同步发送。"
return False, "订阅暂时不可用,请稍后再试"
def _map_destination(self, r: dict) -> dict:
return {

View File

@ -0,0 +1,125 @@
"""Next-stop recommendation scoring — ported from NomadCNA logic, adapted to nomadro destinations."""
from __future__ import annotations
from typing import Any
def _parse_nomads_count(value: str) -> int:
digits = "".join(ch for ch in str(value) if ch.isdigit())
return int(digits) if digits else 0
def score_destination(
dest: dict[str, Any],
*,
budget: int,
internet: int,
climate: str,
tags: list[str],
priority: str = "balanced",
) -> tuple[float, list[str]]:
"""Return (score, match_reasons) for a destination dict."""
cost = int(dest.get("cost") or 0)
speed = int(dest.get("speed") or 0)
temp = int(dest.get("temperature") or 20)
rating = float(dest.get("rating") or 0)
nomads = _parse_nomads_count(dest.get("nomads_count") or "0")
highlights = dest.get("highlights") or []
tag = str(dest.get("tag") or "")
reasons: list[str] = []
score = rating * 8
# Budget fit (monthly cost in CNY-ish units)
if cost <= budget:
score += 28
reasons.append("预算内")
elif cost <= budget * 1.2:
score += 14
reasons.append("略超预算")
else:
score += max(0, 10 - (cost - budget) / 800)
# Internet
if speed >= internet:
score += 22
reasons.append("网络达标")
else:
score += max(0, 22 - (internet - speed) / 4)
reasons.append("需确认住处网络")
# Climate preference
if climate == "warm":
score += 18 if temp >= 24 else max(0, 18 - (24 - temp) * 2)
if temp >= 24:
reasons.append("气候偏暖")
elif climate == "cool":
score += 18 if temp <= 18 else max(0, 18 - (temp - 18) * 2)
if temp <= 18:
reasons.append("气候清爽")
else: # mild
score += 18 if 18 <= temp <= 26 else max(0, 18 - abs(temp - 22) * 2)
if 18 <= temp <= 26:
reasons.append("气候温和")
# Tag overlap (NomadCNA-style lifestyle tags)
dest_tags = set(highlights + [tag])
overlap = [t for t in tags if any(t in dt or dt in t for dt in dest_tags)]
if overlap:
score += min(15, len(overlap) * 5)
reasons.append(f"标签匹配:{overlap[0]}")
# Community density
if nomads >= 8000:
score += 12
reasons.append("社区活跃")
elif nomads >= 3000:
score += 6
# Priority boost
if priority == "cost":
score += (14000 - cost) / 120
elif priority == "speed":
score += speed / 5
elif priority == "community":
score += nomads / 400
if not reasons:
reasons.append("综合评分推荐")
return round(score, 1), reasons[:3]
def recommend_destinations(
destinations: list[dict[str, Any]],
*,
budget: int = 8000,
internet: int = 50,
climate: str = "mild",
tags: list[str] | None = None,
priority: str = "balanced",
limit: int = 12,
) -> list[dict[str, Any]]:
tags = tags or []
scored: list[tuple[float, dict[str, Any], list[str]]] = []
for dest in destinations:
s, reasons = score_destination(
dest,
budget=budget,
internet=internet,
climate=climate,
tags=tags,
priority=priority,
)
scored.append((s, dest, reasons))
scored.sort(key=lambda x: x[0], reverse=True)
items = []
for s, dest, reasons in scored[:limit]:
items.append({
**dest,
"match_score": s,
"match_reasons": reasons,
})
return items

View File

@ -0,0 +1,96 @@
"""SeaweedFS S3-compatible uploads (server: s3.nomadro.com → 127.0.0.1:8333)."""
from __future__ import annotations
import mimetypes
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote
from fastapi import HTTPException, UploadFile
from app.config import settings
@dataclass
class UploadResult:
url: str
object_key: str
size: int
content_type: str
def s3_enabled() -> bool:
return bool(settings.s3_enabled and settings.s3_endpoint and settings.s3_bucket)
def _client():
import boto3
from botocore.client import Config
return boto3.client(
"s3",
endpoint_url=settings.s3_endpoint.rstrip("/"),
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name=settings.s3_region,
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
def ensure_bucket() -> None:
if not s3_enabled():
return
client = _client()
bucket = settings.s3_bucket
try:
client.head_bucket(Bucket=bucket)
except Exception:
try:
client.create_bucket(Bucket=bucket)
except Exception:
pass
def public_url_for_key(key: str) -> str:
base = (settings.s3_public_url or settings.s3_endpoint).rstrip("/")
bucket = settings.s3_bucket.strip("/")
return f"{base}/{bucket}/{quote(key, safe='/')}"
def _object_key(filename: str, purpose: str) -> str:
suffix = Path(filename or "upload.bin").suffix.lower() or ".bin"
day = datetime.now(timezone.utc).strftime("%Y/%m/%d")
prefix = (settings.s3_upload_prefix or "nomadweb").strip("/")
purpose_part = (purpose or "uploads").replace("/", "-")[:40]
return f"{prefix}/{purpose_part}/{day}/{uuid.uuid4().hex}{suffix}"
async def upload_file(file: UploadFile, *, purpose: str, max_size: int) -> UploadResult:
if not s3_enabled():
raise HTTPException(status_code=503, detail="S3 存储未配置")
data = await file.read()
if len(data) > max_size:
raise HTTPException(status_code=400, detail=f"文件不能超过 {max_size // 1024 // 1024}MB")
if not data:
raise HTTPException(status_code=400, detail="空文件")
ensure_bucket()
key = _object_key(file.filename or "upload.bin", purpose)
ctype = file.content_type or mimetypes.guess_type(file.filename or "")[0] or "application/octet-stream"
client = _client()
try:
client.put_object(
Bucket=settings.s3_bucket,
Key=key,
Body=data,
ContentType=ctype,
ACL="public-read",
)
except Exception:
client.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=ctype)
url = public_url_for_key(key)
return UploadResult(url=url, object_key=key, size=len(data), content_type=ctype)

View File

@ -0,0 +1,942 @@
"""Social graph — PocketBase backed (profiles, swipes, matches, DMs)."""
from __future__ import annotations
import json
import secrets
import time
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any
from app.data.social_profiles import CANDIDATE_PROFILES, MATCH_INTENTS
from app.services.pb_client import pb, pb_quote
from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb
LIKE_ACTIONS = frozenset({"like", "right", "superlike"})
FREE_SWIPE_DAILY_LIMIT = 25
VIP_SWIPE_LIMIT = 9999
_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json"
_j: dict[str, Any] = {}
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _today_key() -> str:
return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d")
def _pair_key(a: str, b: str) -> str:
return "|".join(sorted([a, b]))
def _profile_looking_for(record: dict) -> list[str]:
raw = record.get("lookingFor") or record.get("looking_for") or []
cleaned = [x for x in raw if x in MATCH_INTENTS]
return cleaned or ["friends", "explore"]
def _peer_profile(user_id: str) -> dict | None:
for p in CANDIDATE_PROFILES:
if p.get("userId") == user_id:
return p
if use_pb():
row = safe_first("profiles", filter=f"userId={q(user_id)}")
if row:
return {
"id": f"user-{user_id}",
"userId": user_id,
"name": row.get("name", "游民"),
"location": row.get("location", "全球"),
"bio": row.get("bio", ""),
"photo": row.get("photo", "🧑‍💻"),
"tags": row.get("tags") or [],
}
prof = (_jload().get("profiles") or {}).get(user_id)
if prof:
return {"id": f"user-{user_id}", "userId": user_id, **prof}
return None
# ── VIP / membership ───────────────────────────────────────────────────
def is_vip(user_id: str) -> bool:
if use_pb():
row = safe_first("memberships", filter=f"userId={q(user_id)}")
if not row:
return False
return int(row.get("expiresAt") or 0) > int(time.time())
_jload()
m = (_j.get("memberships") or {}).get(user_id)
return bool(m and (m.get("expires_at") or 0) > int(time.time()))
def membership_expires_at(user_id: str) -> int:
if use_pb():
row = safe_first("memberships", filter=f"userId={q(user_id)}")
return int((row or {}).get("expiresAt") or 0)
_jload()
return int(((_j.get("memberships") or {}).get(user_id) or {}).get("expires_at") or 0)
def ensure_membership(user_id: str, days: int = 365) -> None:
exp = int(time.time()) + days * 86400
if use_pb():
row = safe_first("memberships", filter=f"userId={q(user_id)}")
payload = {"userId": user_id, "plan": "vip", "expiresAt": exp}
if row:
safe_update("memberships", row["id"], payload)
else:
safe_create("memberships", payload)
return
_jload()
_j.setdefault("memberships", {})[user_id] = {"plan": "vip", "expires_at": exp, "updated_at": _now_iso()}
_jsave()
# ── Profiles ───────────────────────────────────────────────────────────
def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict:
if use_pb():
row = safe_first("profiles", filter=f"userId={q(user_id)}")
if row:
return row
return safe_create(
"profiles",
{
"userId": user_id,
"name": name,
"location": extra.get("location", "全球"),
"citySlug": extra.get("citySlug", ""),
"gender": extra.get("gender", ""),
"single": extra.get("single", ""),
"bio": extra.get("bio", ""),
"photo": extra.get("photo", "🧑‍💻"),
"tags": extra.get("tags", []),
"lookingFor": extra.get("lookingFor", ["friends", "explore"]),
},
) or {}
_jload()
profiles = _j.setdefault("profiles", {})
if user_id not in profiles:
profiles[user_id] = {
"userId": user_id,
"name": name,
"location": extra.get("location", "全球"),
"citySlug": extra.get("citySlug", ""),
"gender": extra.get("gender", ""),
"single": extra.get("single", ""),
"bio": extra.get("bio", ""),
"photo": extra.get("photo", "🧑‍💻"),
"tags": extra.get("tags", []),
"lookingFor": extra.get("lookingFor", ["friends", "explore"]),
"createdAt": _now_iso(),
}
_jsave()
return profiles[user_id]
def join_member(user_id: str, name: str, payload: dict) -> dict:
profile = get_or_create_profile(
user_id,
name,
location=payload.get("city", "全球"),
citySlug=payload.get("citySlug", ""),
gender=payload.get("gender", ""),
single=payload.get("single", ""),
bio=payload.get("bio", ""),
lookingFor=payload.get("lookingFor", ["friends", "explore"]),
photo=payload.get("photo", "🧑‍💻"),
)
try:
seed_demo_social_activity(user_id)
except Exception:
pass
return profile
def get_public_profile(user_id: str) -> dict | None:
if not user_id:
return None
# Accept /members/user-{id} style links
lookup = user_id[5:] if user_id.startswith("user-") else user_id
if use_pb():
row = safe_first("profiles", filter=f"userId={q(lookup)}")
if row:
return {
"id": row.get("id") or f"user-{lookup}",
"userId": row.get("userId") or lookup,
"name": row.get("name") or "游民",
"location": row.get("location") or "",
"citySlug": row.get("citySlug") or "",
"gender": row.get("gender") or "",
"single": row.get("single") or "",
"bio": row.get("bio") or "",
"photo": row.get("photo") or "🧑‍💻",
"tags": row.get("tags") or [],
"lookingFor": _profile_looking_for(row),
"vip": is_vip(lookup),
}
else:
_jload()
p = (_j.get("profiles") or {}).get(lookup)
if p:
return {
"id": f"user-{lookup}",
"userId": lookup,
"name": p.get("name") or "游民",
"location": p.get("location") or "",
"citySlug": p.get("citySlug") or "",
"gender": p.get("gender") or "",
"single": p.get("single") or "",
"bio": p.get("bio") or "",
"photo": p.get("photo") or "🧑‍💻",
"tags": p.get("tags") or [],
"lookingFor": _profile_looking_for(p),
"vip": is_vip(lookup),
}
for cand in CANDIDATE_PROFILES:
if cand.get("userId") == lookup or cand.get("id") == lookup or cand.get("id") == user_id:
return {
"id": cand.get("id") or f"user-{cand.get('userId', lookup)}",
"userId": cand.get("userId") or lookup,
"name": cand.get("name") or "游民",
"location": cand.get("location") or "",
"citySlug": cand.get("citySlug") or "",
"gender": cand.get("gender") or "",
"single": cand.get("single") or "",
"bio": cand.get("bio") or "",
"photo": cand.get("photo") or "🧑‍💻",
"tags": cand.get("tags") or [],
"lookingFor": _profile_looking_for(cand),
"vip": False,
}
return None
def normalize_peer(peer: dict | None) -> dict | None:
if not peer:
return None
uid = peer.get("userId") or ""
return {
"id": peer.get("id") or (f"user-{uid}" if uid else "unknown"),
"userId": uid,
"name": peer.get("name") or "游民",
"location": peer.get("location") or "",
"citySlug": peer.get("citySlug") or "",
"gender": peer.get("gender") or "",
"single": peer.get("single") or "",
"bio": peer.get("bio") or "",
"photo": peer.get("photo") or "🧑‍💻",
"tags": peer.get("tags") or [],
"lookingFor": _profile_looking_for(peer),
"intent": peer.get("intent") or "",
}
# ── Candidates / swipes ────────────────────────────────────────────────
def list_candidates(
user_id: str,
intent: str = "friends",
city: str = "",
gender: str = "",
single: str = "",
exclude_swiped: bool = True,
) -> list[dict]:
swiped_ids: set[str] = set()
if exclude_swiped:
if use_pb():
for s in safe_list("swipes", filter=f"userId={q(user_id)}"):
swiped_ids.add(s.get("profileId", ""))
else:
_jload()
for s in _j.get("swipes") or []:
if s.get("userId") == user_id:
swiped_ids.add(s.get("profileId", ""))
pool = list(CANDIDATE_PROFILES)
if use_pb():
for row in safe_list("profiles"):
uid = row.get("userId", "")
if uid and uid != user_id:
pool.append({
"id": f"user-{uid}",
"userId": uid,
"name": row.get("name", "游民"),
"location": row.get("location", "全球"),
"citySlug": row.get("citySlug", ""),
"gender": row.get("gender", ""),
"single": row.get("single", ""),
"bio": row.get("bio", ""),
"photo": row.get("photo", "🧑‍💻"),
"tags": row.get("tags") or [],
"lookingFor": _profile_looking_for(row),
})
else:
_jload()
for uid, prof in (_j.get("profiles") or {}).items():
if uid != user_id:
pool.append({
"id": f"user-{uid}",
"userId": uid,
"name": prof.get("name", "游民"),
"location": prof.get("location", "全球"),
"citySlug": prof.get("citySlug", ""),
"gender": prof.get("gender", ""),
"single": prof.get("single", ""),
"bio": prof.get("bio", ""),
"photo": prof.get("photo", "🧑‍💻"),
"tags": prof.get("tags", []),
"lookingFor": _profile_looking_for(prof),
})
results = []
for p in pool:
if p.get("userId") == user_id or p["id"] in swiped_ids:
continue
if intent and intent not in _profile_looking_for(p):
continue
if city and city not in (p.get("location") or "") and city != p.get("citySlug"):
continue
if gender and is_vip(user_id) and p.get("gender") != gender:
continue
if single and is_vip(user_id) and p.get("single") != single:
continue
results.append({**p, "intent": intent})
return results
def swipe_count_today(user_id: str) -> int:
today = _today_key()
if use_pb():
return sum(
1 for s in safe_list("swipes", filter=f"userId={q(user_id)}")
if (s.get("swipeDate") or "") == today
)
_jload()
return sum(1 for s in _j.get("swipes") or [] if s.get("userId") == user_id and s.get("date") == today)
def get_quota(user_id: str) -> dict:
vip = is_vip(user_id)
limit = VIP_SWIPE_LIMIT if vip else FREE_SWIPE_DAILY_LIMIT
used = swipe_count_today(user_id)
return {"vip": vip, "limit": limit, "used": used, "remaining": max(0, limit - used)}
def _find_profile(profile_id: str) -> dict | None:
for p in CANDIDATE_PROFILES:
if p["id"] == profile_id:
return p
if profile_id.startswith("user-"):
uid = profile_id[5:]
return _peer_profile(uid)
return None
def _has_reciprocal_like(target_user_id: str, my_profile_id: str) -> bool:
if use_pb():
rows = safe_list("swipes", filter=f"userId={q(target_user_id)} && profileId={q(my_profile_id)}")
return any(r.get("action") in LIKE_ACTIONS for r in rows)
_jload()
for s in _j.get("swipes") or []:
if s.get("userId") == target_user_id and s.get("profileId") == my_profile_id:
if s.get("action") in LIKE_ACTIONS:
return True
return False
def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", match: bool = False) -> dict:
pk = _pair_key(user_a, user_b)
if use_pb():
row = safe_first("conversations", filter=f"pairKey={q(pk)}")
if row:
return {"id": row["id"], **row}
created = safe_create(
"conversations",
{
"pairKey": pk,
"userAId": user_a,
"userBId": user_b,
"intent": intent,
"lastMessagePreview": "",
"readState": {user_a: _now_iso(), user_b: _now_iso()},
},
)
return {"id": created["id"], **created} if created else {"id": "", "pairKey": pk}
_jload()
for c in _j.get("conversations") or []:
if c.get("pairKey") == pk:
return c
conv = {
"id": secrets.token_hex(8),
"pairKey": pk,
"type": "match" if match else "direct",
"userAId": user_a,
"userBId": user_b,
"intent": intent,
"lastMessageAt": _now_iso(),
"lastMessagePreview": "",
"readState": {user_a: _now_iso(), user_b: _now_iso()},
}
_j.setdefault("conversations", []).append(conv)
_jsave()
return conv
def _ensure_match(user_id: str, profile: dict, intent: str) -> dict | None:
target_uid = profile.get("userId")
if not target_uid:
return None
pk = _pair_key(user_id, target_uid)
if use_pb():
row = safe_first("match_connections", filter=f"pairKey={q(pk)}")
if row:
return row
conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True)
created = safe_create(
"match_connections",
{
"pairKey": pk,
"userAId": user_id,
"userBId": target_uid,
"profileBId": profile.get("id", ""),
"intent": intent,
"conversationId": conv.get("id", ""),
"matchedAt": _now_iso(),
},
)
return created
_jload()
for m in _j.get("matches") or []:
if m.get("pairKey") == pk:
return m
conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True)
match = {
"id": secrets.token_hex(6),
"pairKey": pk,
"userAId": user_id,
"userBId": target_uid,
"profileBId": profile.get("id"),
"intent": intent,
"conversationId": conv["id"],
"matchedAt": _now_iso(),
}
_j.setdefault("matches", []).append(match)
_jsave()
return match
def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict:
if action not in LIKE_ACTIONS and action != "dislike":
action = "dislike"
if action == "superlike" and not is_vip(user_id):
return {"ok": False, "error": "vip_required"}
qdata = get_quota(user_id)
if not qdata["vip"] and qdata["remaining"] <= 0 and action in LIKE_ACTIONS:
return {"ok": False, "error": "quota_exceeded"}
if use_pb():
dup = safe_first(
"swipes",
filter=f"userId={q(user_id)} && profileId={q(profile_id)}",
)
if dup:
return {"ok": False, "error": "duplicate"}
else:
_jload()
for s in _j.get("swipes") or []:
if s.get("userId") == user_id and s.get("profileId") == profile_id:
return {"ok": False, "error": "duplicate"}
profile = _find_profile(profile_id)
if not profile:
return {"ok": False, "error": "not_found"}
swipe_payload = {
"userId": user_id,
"profileId": profile_id,
"action": action,
"intent": intent,
"swipeDate": _today_key(),
}
if use_pb():
safe_create("swipes", swipe_payload)
else:
_jload()
_j.setdefault("swipes", []).append({**swipe_payload, "id": secrets.token_hex(6), "date": _today_key(), "createdAt": _now_iso()})
_jsave()
matched = None
if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id):
matched = _ensure_match(user_id, profile, intent)
if matched:
from app.services.notify import notify_user
my_prof = get_public_profile(user_id) or {}
peer_name = profile.get("name", "游民")
conv_id = matched.get("conversationId", "")
link = f"/chat/{conv_id}" if conv_id else "/chat"
notify_user(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link, "match")
peer_uid = profile.get("userId")
if peer_uid:
notify_user(
peer_uid,
"匹配成功 🎉",
f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧",
link,
"match",
)
return {"ok": True, "matched": bool(matched), "match": matched}
def undo_last_swipe(user_id: str) -> dict:
if use_pb():
rows = safe_list("swipes", filter=f"userId={q(user_id)}")
rows.sort(key=lambda r: r.get("created", ""), reverse=True)
if not rows:
return {"ok": False, "error": "nothing_to_undo"}
last = rows[0]
try:
pb.delete_record("swipes", last["id"])
except Exception:
pass
return {"ok": True, "removed": last}
_jload()
swipes = _j.get("swipes") or []
for i in range(len(swipes) - 1, -1, -1):
if swipes[i].get("userId") == user_id:
removed = swipes.pop(i)
_jsave()
return {"ok": True, "removed": removed}
return {"ok": False, "error": "nothing_to_undo"}
def list_likes_received(user_id: str) -> list[dict]:
my_pid = f"user-{user_id}"
likers = []
if use_pb():
for s in safe_list("swipes", filter=f"profileId={q(my_pid)}"):
if s.get("action") not in LIKE_ACTIONS:
continue
p = _peer_profile(s.get("userId", ""))
if p:
likers.append(p)
return likers
_jload()
for s in _j.get("swipes") or []:
if s.get("profileId") == my_pid and s.get("action") in LIKE_ACTIONS:
p = _peer_profile(s.get("userId", ""))
if p:
likers.append(p)
return likers
def list_likes(user_id: str) -> list[dict]:
out = []
if use_pb():
for s in safe_list("swipes", filter=f"userId={q(user_id)}"):
if s.get("action") in LIKE_ACTIONS:
p = _find_profile(s.get("profileId", ""))
if p:
out.append(p)
return out
_jload()
for s in _j.get("swipes") or []:
if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS:
p = _find_profile(s.get("profileId", ""))
if p:
out.append(p)
return out
def list_mutual(user_id: str) -> list[dict]:
items = []
if use_pb():
rows = safe_list("match_connections", filter=f"userAId={q(user_id)} || userBId={q(user_id)}")
for m in rows:
peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"]
items.append({**m, "peer": _peer_profile(peer_id), "conversationId": m.get("conversationId")})
return items
_jload()
for m in _j.get("matches") or []:
if user_id in (m.get("userAId"), m.get("userBId")):
peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"]
items.append({**m, "peer": _peer_profile(peer_id), "conversationId": m.get("conversationId")})
return items
def list_conversations(user_id: str) -> list[dict]:
items = []
if use_pb():
rows = safe_list("conversations", filter=f"userAId={q(user_id)} || userBId={q(user_id)}")
rows.sort(key=lambda c: c.get("lastMessageAt") or c.get("created", ""), reverse=True)
for c in rows:
if c.get("userAId") == c.get("userBId"):
continue
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
read_at = (c.get("readState") or {}).get(user_id, "")
unread = 1 if (c.get("lastMessageAt") or "") > read_at else 0
items.append({**c, "peer": normalize_peer(_peer_profile(peer_id)), "unreadCount": unread})
return items
_jload()
for c in _j.get("conversations") or []:
if user_id not in (c.get("userAId"), c.get("userBId")):
continue
if c.get("userAId") == c.get("userBId"):
continue
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
read_at = (c.get("readState") or {}).get(user_id, "")
unread = 1 if c.get("lastMessageAt", "") > read_at else 0
items.append({**c, "peer": normalize_peer(_peer_profile(peer_id)), "unreadCount": unread})
items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True)
return items
def get_conversation(conv_id: str, user_id: str) -> dict | None:
if use_pb():
row = safe_first("conversations", filter=f"id={q(conv_id)}")
if not row or user_id not in (row.get("userAId"), row.get("userBId")):
return None
peer_id = row["userBId"] if row["userAId"] == user_id else row["userAId"]
return {**row, "peer": normalize_peer(_peer_profile(peer_id))}
_jload()
for c in _j.get("conversations") or []:
if c["id"] == conv_id and user_id in (c.get("userAId"), c.get("userBId")):
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
return {**c, "peer": normalize_peer(_peer_profile(peer_id))}
return None
def list_messages(conv_id: str, user_id: str, limit: int = 50) -> list[dict]:
conv = get_conversation(conv_id, user_id)
if not conv:
return []
if use_pb():
msgs = safe_list("messages", filter=f"conversationId={q(conv_id)}")
msgs.sort(key=lambda m: m.get("created", ""))
read_state = dict(conv.get("readState") or {})
read_state[user_id] = _now_iso()
safe_update("conversations", conv_id, {"readState": read_state})
return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]]
_jload()
msgs = [m for m in _j.get("messages") or [] if m.get("conversationId") == conv_id]
msgs.sort(key=lambda x: x.get("createdAt", ""))
for c in _j.get("conversations") or []:
if c["id"] == conv_id:
c.setdefault("readState", {})[user_id] = _now_iso()
_jsave()
break
return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]]
def send_message(conv_id: str, user_id: str, body: str) -> dict | None:
conv = get_conversation(conv_id, user_id)
if not conv or not body.strip():
return None
body = body.strip()[:2000]
if use_pb():
msg = safe_create(
"messages",
{"conversationId": conv_id, "senderId": user_id, "body": body},
)
if not msg:
return None
read_state = dict(conv.get("readState") or {})
read_state[user_id] = _now_iso()
safe_update(
"conversations",
conv_id,
{"lastMessageAt": _now_iso(), "lastMessagePreview": body[:80], "readState": read_state},
)
return {**msg, "mine": True, "createdAt": _now_iso()}
_jload()
msg = {
"id": secrets.token_hex(8),
"conversationId": conv_id,
"senderId": user_id,
"body": body,
"createdAt": _now_iso(),
}
_j.setdefault("messages", []).append(msg)
for c in _j.get("conversations") or []:
if c["id"] == conv_id:
c["lastMessageAt"] = _now_iso()
c["lastMessagePreview"] = body[:80]
break
_jsave()
return {**msg, "mine": True}
def grant_ebook(user_id: str) -> None:
if not user_id:
return
if use_pb():
existing = safe_first("ebook_entitlements", filter=f"userId={q(user_id)}")
if existing:
safe_update("ebook_entitlements", existing["id"], {"active": True})
else:
safe_create("ebook_entitlements", {"userId": user_id, "active": True, "product": "nomad-code"})
return
_jload()
_j.setdefault("ebook_entitlements", {})[user_id] = {"active": True, "product": "nomad-code", "updated_at": _now_iso()}
_jsave()
def has_ebook(user_id: str) -> bool:
if not user_id:
return False
if use_pb():
row = safe_first("ebook_entitlements", filter=f"userId={q(user_id)} && active=true")
return bool(row)
_jload()
ent = (_j.get("ebook_entitlements") or {}).get(user_id) or {}
return bool(ent.get("active"))
def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool = False) -> dict:
order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}"
status = "paid" if dev_auto_pay else "pending"
order = {
"orderId": order_id,
"userId": user_id,
"payType": pay_type,
"amount": amount,
"status": status,
}
if use_pb():
safe_create("orders", order)
else:
_jload()
_j.setdefault("orders", {})[order_id] = {**order, "id": order_id, "createdAt": _now_iso()}
_jsave()
if dev_auto_pay:
_fulfill_order(user_id, pay_type)
return {"id": order_id, **order, "createdAt": _now_iso()}
def _fulfill_order(user_id: str, pay_type: str) -> None:
if pay_type == "ebook":
grant_ebook(user_id)
else:
ensure_membership(user_id)
def mark_order_paid(order_id: str) -> dict | None:
if use_pb():
row = safe_first("orders", filter=f"orderId={q(order_id)}")
if not row:
return None
safe_update("orders", row["id"], {"status": "paid"})
_fulfill_order(row.get("userId", ""), row.get("payType") or "join")
return {**row, "status": "paid"}
_jload()
order = (_j.get("orders") or {}).get(order_id)
if not order:
return None
order["status"] = "paid"
_fulfill_order(order.get("userId", ""), order.get("payType") or "join")
_jsave()
return order
def seed_demo_social_activity(user_id: str) -> None:
"""Give a real user a few incoming likes + one chat so Connect feels alive."""
if not user_id:
return
my_pid = f"user-{user_id}"
peers = [p for p in CANDIDATE_PROFILES if p.get("userId")][:4]
if not peers:
return
buddy = peers[0]
buddy_uid = buddy["userId"]
already = False
if use_pb():
already = bool(safe_first("swipes", filter=f"userId={q(buddy_uid)} && profileId={q(my_pid)}"))
else:
_jload()
already = bool((_j.get("demo_seeded") or {}).get(user_id)) or any(
s.get("userId") == buddy_uid and s.get("profileId") == my_pid for s in (_j.get("swipes") or [])
)
if already:
return
# Incoming likes from candidates toward this user
for p in peers[:3]:
peer_uid = p["userId"]
if use_pb():
if not safe_first("swipes", filter=f"userId={q(peer_uid)} && profileId={q(my_pid)}"):
safe_create(
"swipes",
{
"userId": peer_uid,
"profileId": my_pid,
"action": "like",
"intent": "friends",
"swipeDate": _today_key(),
},
)
else:
_jload()
swipes = _j.setdefault("swipes", [])
if not any(s.get("userId") == peer_uid and s.get("profileId") == my_pid for s in swipes):
swipes.append(
{
"id": secrets.token_hex(6),
"userId": peer_uid,
"profileId": my_pid,
"action": "like",
"intent": "friends",
"date": _today_key(),
"createdAt": _now_iso(),
}
)
# Mutual match + messages with first peer
buddy_pid = buddy["id"]
if use_pb():
if not safe_first("swipes", filter=f"userId={q(user_id)} && profileId={q(buddy_pid)}"):
safe_create(
"swipes",
{
"userId": user_id,
"profileId": buddy_pid,
"action": "like",
"intent": "friends",
"swipeDate": _today_key(),
},
)
else:
_jload()
swipes = _j.setdefault("swipes", [])
if not any(s.get("userId") == user_id and s.get("profileId") == buddy_pid for s in swipes):
swipes.append(
{
"id": secrets.token_hex(6),
"userId": user_id,
"profileId": buddy_pid,
"action": "like",
"intent": "friends",
"date": _today_key(),
"createdAt": _now_iso(),
}
)
match = _ensure_match(user_id, buddy, "friends")
conv_id = (match or {}).get("conversationId") or ""
if not conv_id:
conv = _ensure_conversation(user_id, buddy_uid, intent="friends", match=True)
conv_id = conv.get("id", "")
hello = f"嗨!我在{buddy.get('location', '旅途中')},看到你也在用 nomadro,交个朋友呀 👋"
reply = "你好!我也刚到,有机会一起去联合办公或 meetup~"
if conv_id:
if use_pb():
msgs = safe_list("messages", filter=f"conversationId={q(conv_id)}")
if not msgs:
safe_create("messages", {"conversationId": conv_id, "senderId": buddy_uid, "body": hello})
safe_create("messages", {"conversationId": conv_id, "senderId": user_id, "body": reply})
safe_update(
"conversations",
conv_id,
{
"lastMessageAt": _now_iso(),
"lastMessagePreview": reply[:80],
"readState": {user_id: _now_iso(), buddy_uid: _now_iso()},
},
)
else:
_jload()
msgs = [m for m in (_j.get("messages") or []) if m.get("conversationId") == conv_id]
if not msgs:
_j.setdefault("messages", []).extend(
[
{
"id": secrets.token_hex(8),
"conversationId": conv_id,
"senderId": buddy_uid,
"body": hello,
"createdAt": _now_iso(),
},
{
"id": secrets.token_hex(8),
"conversationId": conv_id,
"senderId": user_id,
"body": reply,
"createdAt": _now_iso(),
},
]
)
for c in _j.get("conversations") or []:
if c.get("id") == conv_id:
c["lastMessageAt"] = _now_iso()
c["lastMessagePreview"] = reply[:80]
break
try:
from app.services import community_store
from app.services.notify import notify_user
notify_user(
user_id,
"有人喜欢了你",
f"{buddy.get('name', '游民')} 等人对你感兴趣,去看看吧",
"/dating/likes",
"match",
)
if conv_id:
notify_user(
user_id,
"新消息",
f"{buddy.get('name', '游民')}: {hello[:40]}",
f"/chat/{conv_id}",
"chat",
)
community_store.add_notification(
user_id,
"欢迎来到匹配",
"左滑跳过、右滑喜欢、上滑超级喜欢。匹配成功可私信。",
"/dating",
"system",
)
except Exception:
pass
if not use_pb():
_jload()
_j.setdefault("demo_seeded", {})[user_id] = True
_jsave()
else:
pass
# ── JSON fallback ──────────────────────────────────────────────────────
def _jload() -> dict:
global _j
if _j:
return _j
if _JSON_PATH.exists():
try:
_j = json.loads(_JSON_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
_j = {}
else:
_j = {}
return _j
def _jsave() -> None:
_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
_JSON_PATH.write_text(json.dumps(_j, ensure_ascii=False), encoding="utf-8")

View File

@ -0,0 +1,44 @@
"""Open-Meteo weather for nomad cities."""
from __future__ import annotations
import httpx
from app.data.platform_data import WEATHER_CITIES
async def get_city_weather(slug: str) -> dict | None:
meta = WEATHER_CITIES.get(slug)
if not meta:
return None
url = (
"https://api.open-meteo.com/v1/forecast"
f"?latitude={meta['lat']}&longitude={meta['lng']}"
"&current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code"
"&timezone=auto"
)
try:
async with httpx.AsyncClient(timeout=8) as client:
res = await client.get(url)
res.raise_for_status()
data = res.json()
cur = data.get("current") or {}
return {
"slug": slug,
"name": meta["name"],
"temperature": cur.get("temperature_2m"),
"humidity": cur.get("relative_humidity_2m"),
"wind_speed": cur.get("wind_speed_10m"),
"weather_code": cur.get("weather_code"),
}
except (httpx.HTTPError, KeyError):
return {"slug": slug, "name": meta["name"], "temperature": None, "error": "unavailable"}
async def list_weather() -> list[dict]:
results = []
for slug in WEATHER_CITIES:
w = await get_city_weather(slug)
if w:
results.append(w)
return results

View File

@ -0,0 +1,57 @@
"""WeChat mini-program helpers (jscode2session)."""
from __future__ import annotations
import hashlib
import os
from typing import Any
import httpx
WECHAT_APPID = os.getenv("WECHAT_APPID", "").strip()
WECHAT_SECRET = os.getenv("WECHAT_SECRET", "").strip()
WECHAT_ALLOW_DEVTOOLS = os.getenv("WECHAT_ALLOW_DEVTOOLS", "true").lower() in {
"1", "true", "yes", "on",
}
async def code2session(js_code: str) -> dict[str, Any]:
code = (js_code or "").strip()
if not code:
raise RuntimeError("缺少微信登录 code")
# Local / 开发者工具联调:无密钥或 mock code 时降级
mock = code in {"dev", "the code is a mock one"} or code.startswith("mock")
if mock or (not WECHAT_APPID or not WECHAT_SECRET):
if WECHAT_ALLOW_DEVTOOLS or mock:
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()[:16]
return {
"openid": f"devtools_{digest}",
"session_key": f"devtools_session_{digest}",
"unionid": None,
}
raise RuntimeError("未配置 WECHAT_APPID / WECHAT_SECRET")
url = "https://api.weixin.qq.com/sns/jscode2session"
params = {
"appid": WECHAT_APPID,
"secret": WECHAT_SECRET,
"js_code": code,
"grant_type": "authorization_code",
}
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url, params=params)
data = r.json()
err = data.get("errcode")
if err:
if WECHAT_ALLOW_DEVTOOLS and int(err) in {40029, 40163, 40252}:
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()[:16]
return {
"openid": f"devtools_{digest}",
"session_key": f"devtools_session_{digest}",
"unionid": None,
}
raise RuntimeError(f"code2session failed: {data}")
if not data.get("openid"):
raise RuntimeError(f"code2session missing openid: {data}")
return data

View File

@ -5,3 +5,5 @@ pydantic[email]==2.10.4
pydantic-settings==2.7.0
python-dotenv==1.0.1
email-validator==2.2.0
boto3==1.35.99
python-multipart==0.0.20

View File

@ -0,0 +1,460 @@
#!/usr/bin/env python3
"""Bootstrap PocketBase collections for nomadweb (run on server after deploy)."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.data import community_data # noqa: E402
from app.services.pb_client import PocketBaseError, pb # noqa: E402
def text(name: str, *, required: bool = False, max: int = 0) -> dict[str, Any]:
return {"name": name, "type": "text", "required": required, "max": max}
def number(name: str, *, only_int: bool = False) -> dict[str, Any]:
return {"name": name, "type": "number", "onlyInt": only_int}
def boolean(name: str) -> dict[str, Any]:
return {"name": name, "type": "bool"}
def json_field(name: str) -> dict[str, Any]:
return {"name": name, "type": "json"}
def date(name: str) -> dict[str, Any]:
return {"name": name, "type": "date"}
COLLECTIONS: dict[str, list[dict[str, Any]]] = {
"destinations": [
text("slug", required=True, max=80),
text("name", required=True, max=120),
text("country", max=80),
text("emoji", max=16),
text("region", max=80),
number("cost", only_int=True),
number("speed", only_int=True),
number("temperature", only_int=True),
number("rating"),
number("hue", only_int=True),
text("tag", max=80),
text("description", max=500),
text("nomads_count", max=40),
json_field("highlights"),
number("map_x"),
number("map_y"),
],
"discussions": [
text("title", required=True, max=255),
text("author", max=120),
text("authorUserId", max=80),
text("authorEmoji", max=16),
text("category", max=80),
json_field("tags"),
text("excerpt", max=1000),
number("replies", only_int=True),
number("views", only_int=True),
number("likes", only_int=True),
boolean("pinned"),
text("legacyId", max=120),
],
"discussion_replies": [
text("discussionId", required=True, max=80),
text("userId", max=80),
text("author", max=120),
text("authorEmoji", max=16),
text("body", required=True, max=2000),
],
"discussion_likes": [
text("discussionId", required=True, max=80),
text("userId", required=True, max=80),
],
"meetups": [
text("legacyId", max=120),
text("title", required=True, max=255),
text("city", max=120),
text("destinationSlug", max=80),
text("emoji", max=16),
date("date"),
text("time", max=20),
text("venue", max=255),
text("description", max=1000),
number("rsvpCount", only_int=True),
number("maxAttendees", only_int=True),
text("organizer", max=120),
text("organizerId", max=80),
text("mode", max=40),
text("accessLevel", max=40),
text("mirotalkRoom", max=160),
text("loungeChannel", max=120),
json_field("tags"),
boolean("isUpcoming"),
text("status", max=40),
],
"meetup_rsvps": [
text("meetupId", required=True, max=80),
text("userId", required=True, max=80),
text("status", max=40),
],
"gigs": [
text("legacyId", max=120),
text("title", required=True, max=255),
text("category", max=120),
number("budget", only_int=True),
text("location", max=120),
text("description", max=1000),
text("status", max=40),
text("posterId", max=80),
text("posterName", max=120),
],
"gig_applications": [
text("gigId", required=True, max=80),
text("userId", max=80),
text("applicant", max=255),
text("message", max=1000),
text("status", max=40),
],
"profiles": [
text("userId", max=80),
text("email", max=255),
text("name", max=120),
number("age", only_int=True),
text("location", max=120),
json_field("tags"),
text("bio", max=1000),
text("photo", max=500),
text("gender", max=40),
json_field("lookingFor"),
text("citySlug", max=80),
],
"swipes": [
text("userId", max=80),
text("profileId", max=80),
text("action", max=40),
text("intent", max=40),
text("swipeDate", max=20),
],
"match_connections": [
text("pairKey", max=160),
text("userAId", max=80),
text("userBId", max=80),
text("intent", max=40),
text("conversationId", max=80),
date("matchedAt"),
],
"conversations": [
text("pairKey", max=160),
text("userAId", max=80),
text("userBId", max=80),
text("intent", max=40),
text("lastMessagePreview", max=200),
date("lastMessageAt"),
json_field("readState"),
],
"messages": [
text("conversationId", required=True, max=80),
text("senderId", required=True, max=80),
text("body", required=True, max=2000),
],
"notifications": [
text("title", required=True, max=255),
text("body", max=2000),
text("category", max=80),
text("targetUserId", max=80),
text("actionUrl", max=500),
text("status", max=40),
boolean("read"),
boolean("archived"),
boolean("pinned"),
],
"nomad_accounts": [
text("email", required=True, max=255),
text("passwordHash", max=128),
text("name", max=120),
text("avatar", max=16),
text("legacyUserId", max=80),
json_field("favorites"),
json_field("plan"),
],
"nomad_sessions": [
text("token", required=True, max=128),
text("userId", required=True, max=80),
number("expiresAt", only_int=True),
],
"media_assets": [
text("userId", max=80),
text("url", required=True, max=500),
text("filename", max=255),
text("contentType", max=120),
number("size", only_int=True),
text("objectKey", max=500),
],
"subscriptions": [
text("email", required=True, max=255),
],
"visas": [text("country", max=80), text("type", max=80), number("difficulty", only_int=True)],
"faqs": [text("question", max=500), text("answer", max=2000), number("order", only_int=True)],
"blog_posts": [
text("slug", max=120),
text("title", max=255),
text("excerpt", max=500),
date("published_at"),
],
"tools": [text("name", max=120), text("url", max=500), text("description", max=500)],
"memberships": [
text("userId", required=True, max=80),
text("plan", max=40),
number("expiresAt", only_int=True),
],
"orders": [
text("orderId", required=True, max=120),
text("userId", max=120),
text("payType", max=60),
number("amount", only_int=True),
text("status", max=40),
],
"ebook_entitlements": [
text("userId", required=True, max=80),
boolean("active"),
text("product", max=80),
],
"feedback": [
text("type", max=40),
text("email", max=255),
text("title", max=255),
text("content", max=2000),
text("status", max=40),
],
"service_leads": [
text("serviceSlug", required=True, max=120),
text("userId", max=80),
text("email", max=255),
text("note", max=1000),
text("status", max=40),
],
"content_submissions": [
text("title", required=True, max=255),
text("type", max=40),
text("mediaUrl", max=500),
text("submittedBy", max=80),
text("authorName", max=120),
text("description", max=2000),
text("reviewStatus", max=40),
],
"volunteer_applications": [
text("citySlug", required=True, max=120),
text("userId", max=80),
text("applicantName", max=140),
text("email", max=255),
text("motivation", max=2000),
text("status", max=40),
],
"reports": [
text("reporterId", max=80),
text("targetType", max=80),
text("targetId", max=120),
text("reason", max=2000),
text("status", max=40),
],
"notification_preferences": [
text("userId", required=True, max=80),
json_field("channels"),
],
"city_details": [
text("citySlug", required=True, max=120),
json_field("guide"),
json_field("pros"),
json_field("reviews"),
json_field("cost"),
json_field("people"),
json_field("chat"),
json_field("photos"),
json_field("weather"),
json_field("trends"),
json_field("demographics"),
json_field("relatedContent"),
],
"routes": [
text("slug", required=True, max=120),
text("title", required=True, max=255),
text("titleEn", max=255),
json_field("citySlugs"),
number("durationDays", only_int=True),
number("budget", only_int=True),
text("description", max=1000),
text("descriptionEn", max=1000),
json_field("stops"),
text("status", max=40),
text("emoji", max=16),
],
"content_items": [
text("slug", required=True, max=120),
text("type", required=True, max=40),
text("title", required=True, max=255),
text("titleEn", max=255),
text("subtitle", max=500),
text("description", max=2000),
text("coverImage", max=500),
text("mediaUrl", max=500),
text("targetUrl", max=500),
text("ctaLabel", max=120),
json_field("citySlugs"),
number("sortOrder", only_int=True),
text("status", max=40),
text("authorName", max=120),
],
"recommendation_logs": [
text("userId", max=80),
number("budget", only_int=True),
number("internet", only_int=True),
text("climate", max=80),
json_field("tags"),
json_field("resultSlugs"),
],
}
def collection_exists(name: str) -> bool:
try:
pb.request("GET", f"/api/collections/{name}", admin=True)
return True
except PocketBaseError:
return False
def ensure_collection(name: str, fields: list[dict[str, Any]]) -> None:
if collection_exists(name):
print(f"exists: {name}")
return
payload = {
"name": name,
"type": "base",
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"fields": fields,
}
pb.request("POST", "/api/collections", admin=True, json=payload)
print(f"created: {name}")
def seed_if_empty(collection: str, records: list[dict[str, Any]]) -> None:
data = pb.list_records(collection, per_page=1)
if (data.get("totalItems") or 0) > 0:
print(f"seed skip: {collection}")
return
for record in records:
pb.create_record(collection, record)
print(f"seeded: {collection} ({len(records)})")
def meetup_payload(m: dict[str, Any]) -> dict[str, Any]:
date_val = m.get("date")
if date_val and "T" not in str(date_val):
date_val = f"{date_val} 00:00:00.000Z"
return {
"legacyId": m.get("id", ""),
"title": m.get("title", ""),
"city": m.get("city", ""),
"destinationSlug": m.get("destination_slug", ""),
"emoji": m.get("emoji", ""),
"date": date_val,
"time": m.get("time", ""),
"venue": m.get("venue", ""),
"description": m.get("description", ""),
"rsvpCount": m.get("rsvp_count", 0),
"maxAttendees": m.get("max_attendees", 30),
"organizer": m.get("organizer", ""),
"mode": m.get("mode", "offline"),
"accessLevel": m.get("access_level", "public"),
"mirotalkRoom": m.get("mirotalkRoom", ""),
"loungeChannel": m.get("loungeChannel", ""),
"tags": m.get("tags", []),
"isUpcoming": m.get("is_upcoming", True),
"status": "published",
}
def discussion_payload(d: dict[str, Any]) -> dict[str, Any]:
return {
"legacyId": d.get("id", ""),
"title": d.get("title", ""),
"author": d.get("author", ""),
"authorUserId": d.get("author_id", ""),
"authorEmoji": d.get("author_emoji", "🧑‍💻"),
"category": d.get("category", "社区"),
"tags": d.get("tags", []),
"excerpt": d.get("excerpt", ""),
"replies": d.get("reply_count", 0),
"views": d.get("view_count", 0),
"likes": d.get("like_count", 0),
"pinned": d.get("is_pinned", False),
}
def main() -> None:
if not pb.health_ok():
print("PocketBase not reachable")
sys.exit(1)
pb.admin_token()
for name, fields in COLLECTIONS.items():
ensure_collection(name, fields)
seed_if_empty("meetups", [meetup_payload(m) for m in community_data.MEETUPS])
seed_if_empty("discussions", [discussion_payload(d) for d in community_data.DISCUSSIONS])
gigs = getattr(community_data, "GIGS", [])
if gigs:
seed_if_empty(
"gigs",
[
{
"legacyId": g.get("id", ""),
"title": g.get("title", ""),
"category": g.get("category", ""),
"budget": g.get("budget", 0) if isinstance(g.get("budget"), int) else 0,
"location": g.get("location", ""),
"description": g.get("description", ""),
"status": g.get("status", "open"),
}
for g in gigs
],
)
from app.data.city_details_data import CITY_DETAILS, CONTENT_ITEMS, EXTRA_ROUTES
seed_if_empty("city_details", CITY_DETAILS)
seed_if_empty(
"routes",
[
{
"slug": r["slug"],
"title": r["title"],
"titleEn": r.get("titleEn", ""),
"citySlugs": r.get("citySlugs") or [],
"durationDays": r.get("durationDays", 0),
"budget": r.get("budget", 0),
"description": r.get("description", ""),
"descriptionEn": r.get("descriptionEn", ""),
"stops": r.get("stops") or [],
"status": r.get("status", "published"),
"emoji": r.get("emoji", "🗺️"),
}
for r in EXTRA_ROUTES
],
)
seed_if_empty("content_items", CONTENT_ITEMS)
print("done")
if __name__ == "__main__":
main()

View File

@ -1,5 +1,5 @@
"""
Seed PocketBase collections with NomadFlow data.
"""
Seed PocketBase collections with nomadro data.
Run after PocketBase is started: python seed_pocketbase.py
"""
import asyncio

View File

@ -0,0 +1,15 @@
lounge.nomadro.com {
encode gzip
reverse_proxy https://lounge.nomadro.cn {
header_up Host lounge.nomadro.cn
header_down -Content-Security-Policy
header_down -X-Frame-Options
transport http {
tls_insecure_skip_verify
}
}
header {
Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001"
-X-Frame-Options
}
}

View File

@ -0,0 +1,16 @@
mirotalk.nomadro.com {
encode gzip
reverse_proxy https://mirotalk.nomadro.cn {
header_up Host mirotalk.nomadro.cn
# Upstream CSP omits nomadweb.nomadro.com; strip so our frame-ancestors win.
header_down -Content-Security-Policy
header_down -X-Frame-Options
transport http {
tls_insecure_skip_verify
}
}
header {
Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001"
-X-Frame-Options
}
}

View File

@ -0,0 +1,55 @@
# Copy to nomadro-api.env on the server — DO NOT commit real keys
DEV_AUTO_PAY=false
PAYMENT_PROVIDER=zpay
PAYMENT_JOIN_AMOUNT=10000
PAYMENT_DEFAULT_AMOUNT=10000
PAYMENT_EBOOK_AMOUNT=2900
SITE_BASE_URL=https://nomadweb.nomadro.com
EBOOK_PDF_URL=
EBOOK_EPUB_URL=
# PocketBase (host process :8090)
POCKETBASE_URL=http://127.0.0.1:8090
POCKETBASE_ADMIN_EMAIL=admin@nomadro.com
POCKETBASE_ADMIN_PASSWORD=
# SeaweedFS S3 (s3.nomadro.com → 127.0.0.1:8333)
S3_ENABLED=true
S3_ENDPOINT=http://127.0.0.1:8333
S3_PUBLIC_URL=https://s3.nomadro.com
S3_ACCESS_KEY=nomadweb
S3_SECRET_KEY=
S3_BUCKET=nomadweb
S3_REGION=us-east-1
S3_UPLOAD_PREFIX=nomadweb
# ntfy push (docker on :2586)
NTFY_ENABLED=true
NTFY_URL=http://127.0.0.1:2586
# Optional Listmonk (leave empty = store subscriptions only)
LISTMONK_URL=
LISTMONK_USER=
LISTMONK_PASSWORD=
LISTMONK_LIST_ID=1
ZPAY_PID=
ZPAY_KEY=
ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php
ZPAY_MAPI_URL=https://zpayz.cn/mapi.php
ZPAY_QUERY_URL=https://zpayz.cn/api.php
ZPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/zpay_notify
XORPAY_AID=
XORPAY_SECRET=
XORPAY_CASHIER_URL=https://xorpay.com/api/cashier
XORPAY_QUERY_URL=https://xorpay.com/api/query2
XORPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/xorpay_notify
# 微信小程序登录(jscode2session)
WECHAT_APPID=wx11f6afc44ea1532f
WECHAT_SECRET=d1814f60f900cfdee1813ccc6e00bc2d
WECHAT_ALLOW_DEVTOOLS=true
MIROTALK_URL=https://mirotalk.nomadro.com
LOUNGE_URL=https://lounge.nomadro.com

View File

@ -0,0 +1,19 @@
[Unit]
Description=nomadro FastAPI backend
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/nomadweb/backend
EnvironmentFile=/opt/nomadweb/deploy/nomadro-api.env
Environment=POCKETBASE_URL=http://127.0.0.1:8090
Environment=POCKETBASE_ADMIN_EMAIL=admin@nomadro.com
Environment=POCKETBASE_ADMIN_PASSWORD=Xiao4669805
Environment=CORS_ORIGINS=https://nomadweb.nomadro.com,http://nomadweb.nomadro.com
ExecStart=/opt/nomadweb/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8055 --workers 2
Restart=always
RestartSec=3
User=root
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,21 @@
[Unit]
Description=nomadro Next.js frontend
After=network.target
Wants=nomadro-api.service
[Service]
Type=simple
WorkingDirectory=/opt/nomadweb/frontend/.next/standalone
Environment=NODE_ENV=production
Environment=PORT=3055
Environment=HOSTNAME=127.0.0.1
Environment=NEXT_TELEMETRY_DISABLED=1
Environment=NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1
Environment=NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=3
User=root
[Install]
WantedBy=multi-user.target

View File

@ -18,7 +18,7 @@ services:
- "127.0.0.1:8055:8000"
environment:
- POCKETBASE_URL=http://pocketbase:8090
- POCKETBASE_ADMIN_EMAIL=${POCKETBASE_ADMIN_EMAIL:-admin@nomadflow.io}
- POCKETBASE_ADMIN_EMAIL=${POCKETBASE_ADMIN_EMAIL:-admin@nomadro.com}
- POCKETBASE_ADMIN_PASSWORD=${POCKETBASE_ADMIN_PASSWORD:-admin123456}
- CORS_ORIGINS=${CORS_ORIGINS:-https://nomadweb.nomadro.com,http://nomadweb.nomadro.com}
depends_on:
@ -29,6 +29,7 @@ services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://nomadweb.nomadro.com/api/v1}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://nomadweb.nomadro.com}

View File

@ -20,7 +20,7 @@ services:
- "8000:8000"
environment:
- POCKETBASE_URL=http://pocketbase:8090
- POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io
- POCKETBASE_ADMIN_EMAIL=admin@nomadro.com
- POCKETBASE_ADMIN_PASSWORD=admin123456
- CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
depends_on:

15
dymini/README.md Normal file
View File

@ -0,0 +1,15 @@
# dymini · nomadro 抖音小程序
参考 Desktop/dymini(Taro 4 + React / tt),对接:
`https://nomadweb.nomadro.com/api/v1`
## 开发
```bash
cd dymini
npm install --legacy-peer-deps
npm run dev:tt
```
抖音开发者工具打开 `dymini/dist`。请替换 `appid` 并配置合法域名。

3
dymini/babel.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
presets: [['taro', { framework: 'react', ts: true, compiler: 'webpack5' }]],
}

5
dymini/config/dev.ts Normal file
View File

@ -0,0 +1,5 @@
export default {
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'http://127.0.0.1:8000/api/v1'),
},
}

37
dymini/config/index.ts Normal file
View File

@ -0,0 +1,37 @@
import path from 'path'
import { defineConfig, type UserConfigExport } from '@tarojs/cli'
export default defineConfig(async (merge) => {
const baseConfig: UserConfigExport = {
projectName: 'dymini',
date: '2026-9-27',
designWidth: 750,
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
sourceRoot: 'src',
outputRoot: 'dist',
plugins: ['@tarojs/plugin-framework-react'],
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'),
},
copy: { patterns: [], options: {} },
framework: 'react',
compiler: { type: 'webpack5', prebundle: { enable: false } },
cache: { enable: false },
alias: { '@': path.resolve(__dirname, '..', 'src') },
mini: {
postcss: {
pxtransform: { enable: true, config: {} },
cssModules: { enable: false },
},
},
h5: {
publicPath: '/',
staticDirectory: 'static',
postcss: { autoprefixer: { enable: true }, cssModules: { enable: false } },
},
}
if (process.env.NODE_ENV === 'development') {
return merge({}, baseConfig, { logger: { quiet: false, stats: true } })
}
return merge({}, baseConfig)
})

5
dymini/config/prod.ts Normal file
View File

@ -0,0 +1,5 @@
export default {
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'),
},
}

33
dymini/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "nomadweb-dymini",
"version": "1.0.0",
"private": true,
"description": "nomadro 抖音小程序",
"scripts": {
"build:tt": "taro build --type tt",
"dev:tt": "npm run build:tt -- --watch"
},
"dependencies": {
"@babel/runtime": "^7.26.0",
"@tarojs/components": "4.0.9",
"@tarojs/helper": "4.0.9",
"@tarojs/plugin-framework-react": "4.0.9",
"@tarojs/plugin-platform-tt": "4.0.9",
"@tarojs/react": "4.0.9",
"@tarojs/runtime": "4.0.9",
"@tarojs/shared": "4.0.9",
"@tarojs/taro": "4.0.9",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@tarojs/cli": "4.0.9",
"@tarojs/webpack5-runner": "4.0.9",
"@types/react": "^18.3.12",
"babel-preset-taro": "4.0.9",
"sass": "^1.81.0",
"typescript": "^5.6.3",
"webpack": "5.91.0"
}
}

View File

@ -0,0 +1,12 @@
{
"miniprogramRoot": "dist/",
"projectname": "nomadweb-dymini",
"description": "nomadro 抖音小程序",
"appid": "ttXXXXXXXX",
"setting": {
"urlCheck": false,
"es6": false,
"minified": false
},
"compileType": "microApp"
}

10
dymini/project.tt.json Normal file
View File

@ -0,0 +1,10 @@
{
"miniprogramRoot": "./",
"projectname": "nomadweb-dymini",
"description": "nomadro 抖音小程序",
"appid": "ttXXXXXXXX",
"setting": {
"urlCheck": false
},
"compileType": "microApp"
}

28
dymini/src/app.config.ts Normal file
View File

@ -0,0 +1,28 @@
export default defineAppConfig({
pages: [
'pages/home/index',
'pages/destinations/index',
'pages/destination/index',
'pages/meetups/index',
'pages/mine/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#0c0b12',
navigationBarTitleText: 'nomadro',
navigationBarTextStyle: 'white',
backgroundColor: '#0c0b12',
},
tabBar: {
color: '#8a8694',
selectedColor: '#f0c674',
backgroundColor: '#12111a',
borderStyle: 'black',
list: [
{ pagePath: 'pages/home/index', text: '首页' },
{ pagePath: 'pages/destinations/index', text: '目的地' },
{ pagePath: 'pages/meetups/index', text: '活动' },
{ pagePath: 'pages/mine/index', text: '我的' },
],
},
})

5
dymini/src/app.scss Normal file
View File

@ -0,0 +1,5 @@
page {
background: #0c0b12;
color: #f4f0ea;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

8
dymini/src/app.tsx Normal file
View File

@ -0,0 +1,8 @@
import { PropsWithChildren } from 'react'
import './app.scss'
function App({ children }: PropsWithChildren) {
return children
}
export default App

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '城市详情' })

View File

@ -0,0 +1,10 @@
.detail { padding: 40px 32px; }
.name { display: block; font-size: 48px; font-weight: 700; }
.meta { display: block; margin-top: 8px; color: #a8a3b3; }
.tag { display: block; margin-top: 16px; color: #cfc9d8; }
.grid { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 40px; }
.cell { width: calc(50% - 8px); background: rgba(255,255,255,0.06); border-radius: 16px; padding: 24px; box-sizing: border-box; }
.k { display: block; color: #8a8694; font-size: 22px; }
.v { display: block; margin-top: 8px; font-size: 30px; font-weight: 600; color: #f0c674; }
.err { color: #e88; }

View File

@ -0,0 +1,33 @@
import { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { useLoad } from '@tarojs/taro'
import { fetchDestination, type Destination } from '../../services/api'
import './index.scss'
export default function DestinationPage() {
const [d, setD] = useState<Destination | null>(null)
const [err, setErr] = useState('')
useLoad((q) => {
const slug = String(q.slug || '')
if (!slug) { setErr('缺少城市'); return }
fetchDestination(slug).then(setD).catch((e) => setErr(e.message || '加载失败'))
})
if (err) return <View className='detail'><Text className='err'>{err}</Text></View>
if (!d) return <View className='detail'><Text>加载中…</Text></View>
return (
<View className='detail'>
<Text className='name'>{d.name}</Text>
<Text className='meta'>{d.country}</Text>
<Text className='tag'>{d.tagline}</Text>
<View className='grid'>
<View className='cell'><Text className='k'>月生活费</Text><Text className='v'>¥{d.cost ?? '—'}</Text></View>
<View className='cell'><Text className='k'>网速</Text><Text className='v'>{d.speed ?? '—'} Mbps</Text></View>
<View className='cell'><Text className='k'>评分</Text><Text className='v'>{d.rating ?? '—'}</Text></View>
<View className='cell'><Text className='k'>气候</Text><Text className='v'>{d.climate || '—'}</Text></View>
</View>
</View>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '目的地' })

View File

@ -0,0 +1,9 @@
.list { height: 100vh; padding: 32px 28px 80px; box-sizing: border-box; }
.h { display: block; font-size: 40px; font-weight: 700; margin-bottom: 28px; }
.err { display: block; color: #e88; margin-bottom: 16px; }
.empty { display: block; color: #8a8694; margin-top: 40px; text-align: center; }
.card { background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px; margin-bottom: 20px; }
.name { display: block; font-size: 32px; font-weight: 600; }
.meta { display: block; margin-top: 10px; color: #a8a3b3; font-size: 24px; }
.tag { display: block; margin-top: 8px; color: #8a8694; font-size: 22px; }

View File

@ -0,0 +1,34 @@
import { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { fetchDestinations, type Destination } from '../../services/api'
import './index.scss'
export default function DestinationsPage() {
const [items, setItems] = useState<Destination[]>([])
const [err, setErr] = useState('')
useLoad(() => {
fetchDestinations()
.then(setItems)
.catch((e) => setErr(e.message || '加载失败'))
})
return (
<ScrollView scrollY className='list'>
<Text className='h'>热门目的地</Text>
{err ? <Text className='err'>{err}</Text> : null}
{items.map((d) => (
<View
key={d.slug}
className='card'
onClick={() => Taro.navigateTo({ url: `/pages/destination/index?slug=${d.slug}` })}
>
<Text className='name'>{d.name}</Text>
<Text className='meta'>{d.country || ''} · ¥{d.cost ?? '—'} /月 · ★{d.rating ?? '—'}</Text>
<Text className='tag'>{d.tagline || d.climate || ''}</Text>
</View>
))}
</ScrollView>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '首页' })

View File

@ -0,0 +1,12 @@
.home { padding: 64px 40px 80px; min-height: 100vh; box-sizing: border-box; }
.brand { display: block; font-size: 56px; font-weight: 700; letter-spacing: 2px; }
.tagline { display: block; margin-top: 12px; color: #a8a3b3; font-size: 28px; }
.err { display: block; margin-top: 24px; color: #e88; font-size: 24px; }
.stats { display: flex; gap: 24px; margin: 56px 0 48px; }
.stat { flex: 1; background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px 16px; text-align: center; }
.n { display: block; font-size: 40px; font-weight: 700; color: #f0c674; }
.l { display: block; margin-top: 8px; font-size: 22px; color: #8a8694; }
.cta { margin-bottom: 20px; background: #f0c674 !important; color: #1a1520 !important; border-radius: 999px !important; font-weight: 600; }
.cta.ghost { background: transparent !important; color: #f4f0ea !important; border: 1px solid rgba(255,255,255,0.2) !important; }
.web { display: block; margin-top: 40px; text-align: center; color: #6a6575; font-size: 22px; }

View File

@ -0,0 +1,32 @@
import { useState } from 'react'
import { View, Text, Button } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { fetchStats } from '../../services/api'
import './index.scss'
export default function HomePage() {
const [stats, setStats] = useState<{ destinations?: number; members?: number; meetups?: number } | null>(null)
const [err, setErr] = useState('')
useLoad(() => {
fetchStats()
.then(setStats)
.catch((e) => setErr(e.message || '加载失败'))
})
return (
<View className='home'>
<Text className='brand'>nomadro</Text>
<Text className='tagline'>用一行代码环游世界</Text>
{err ? <Text className='err'>{err}</Text> : null}
<View className='stats'>
<View className='stat'><Text className='n'>{stats?.destinations ?? '—'}</Text><Text className='l'>目的地</Text></View>
<View className='stat'><Text className='n'>{stats?.members ?? '—'}</Text><Text className='l'>游民</Text></View>
<View className='stat'><Text className='n'>{stats?.meetups ?? '—'}</Text><Text className='l'>活动</Text></View>
</View>
<Button className='cta' onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}>探索城市</Button>
<Button className='cta ghost' onClick={() => Taro.switchTab({ url: '/pages/meetups/index' })}>同城活动</Button>
<Text className='web'>网页版 nomadweb.nomadro.com</Text>
</View>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '活动' })

View File

@ -0,0 +1,9 @@
.list { height: 100vh; padding: 32px 28px 80px; box-sizing: border-box; }
.h { display: block; font-size: 40px; font-weight: 700; margin-bottom: 28px; }
.err { display: block; color: #e88; margin-bottom: 16px; }
.empty { display: block; color: #8a8694; margin-top: 40px; text-align: center; }
.card { background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px; margin-bottom: 20px; }
.name { display: block; font-size: 32px; font-weight: 600; }
.meta { display: block; margin-top: 10px; color: #a8a3b3; font-size: 24px; }
.tag { display: block; margin-top: 8px; color: #8a8694; font-size: 22px; }

View File

@ -0,0 +1,28 @@
import { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import { useLoad } from '@tarojs/taro'
import { fetchMeetups, type Meetup } from '../../services/api'
import './index.scss'
export default function MeetupsPage() {
const [items, setItems] = useState<Meetup[]>([])
const [err, setErr] = useState('')
useLoad(() => {
fetchMeetups().then(setItems).catch((e) => setErr(e.message || '加载失败'))
})
return (
<ScrollView scrollY className='list'>
<Text className='h'>游民活动</Text>
{err ? <Text className='err'>{err}</Text> : null}
{items.map((m) => (
<View key={m.id} className='card'>
<Text className='name'>{m.title}</Text>
<Text className='meta'>{m.city || '全球'} · {m.date || '待定'} · {m.attendees ?? 0} 人</Text>
</View>
))}
{!err && items.length === 0 ? <Text className='empty'>暂无活动</Text> : null}
</ScrollView>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '我的' })

View File

@ -0,0 +1,5 @@
.mine { padding: 48px 32px; }
.h { display: block; font-size: 40px; font-weight: 700; }
.hint { display: block; margin: 20px 0 40px; color: #8a8694; font-size: 26px; }
.cta { background: #f0c674 !important; color: #1a1520 !important; border-radius: 999px !important; }

View File

@ -0,0 +1,15 @@
import { View, Text, Button } from '@tarojs/components'
import Taro from '@tarojs/taro'
import './index.scss'
export default function MinePage() {
return (
<View className='mine'>
<Text className='h'>我的</Text>
<Text className='hint'>登录与会员请使用网页版完整能力</Text>
<Button className='cta' onClick={() => Taro.setClipboardData({ data: 'https://nomadweb.nomadro.com' })}>
复制网站链接
</Button>
</View>
)
}

View File

@ -0,0 +1,38 @@
import { request } from '../utils/request'
export type Destination = {
slug: string
name: string
country?: string
cost?: number
speed?: number
rating?: number
tagline?: string
image?: string
climate?: string
}
export type Meetup = {
id: string
title: string
city?: string
date?: string
cover?: string
attendees?: number
}
export function fetchDestinations() {
return request<Destination[]>('/destinations')
}
export function fetchDestination(slug: string) {
return request<Destination>(`/destinations/${encodeURIComponent(slug)}`)
}
export function fetchMeetups() {
return request<Meetup[]>('/meetups')
}
export function fetchStats() {
return request<{ destinations?: number; members?: number; meetups?: number }>('/stats')
}

View File

@ -0,0 +1,8 @@
declare const TARO_APP_API: string
export const API_BASE =
typeof TARO_APP_API === 'string' && TARO_APP_API
? TARO_APP_API
: 'https://nomadweb.nomadro.com/api/v1'
export const STORAGE_TOKEN = 'dymini_token'

View File

@ -0,0 +1,28 @@
import Taro from '@tarojs/taro'
import { API_BASE, STORAGE_TOKEN } from './config'
type Opts = {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: Record<string, unknown>
header?: Record<string, string>
}
export async function request<T = unknown>(options: Opts): Promise<T> {
const token = Taro.getStorageSync(STORAGE_TOKEN)
const res = await Taro.request({
url: `${API_BASE}${options.url}`,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.header || {}),
},
})
if (res.statusCode >= 400) {
const msg = (res.data as any)?.detail || (res.data as any)?.message || '请求失败'
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg))
}
return res.data as T
}

24
dymini/tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"jsx": "react-jsx",
"strictNullChecks": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"typeRoots": [
"node_modules/@types"
]
},
"include": [
"./src",
"./types",
"./config"
]
}

3
dymini/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare function defineAppConfig(config: Record<string, unknown>): Record<string, unknown>
declare function definePageConfig(config: Record<string, unknown>): Record<string, unknown>
declare const TARO_APP_API: string

15
frontend/.dockerignore Normal file
View File

@ -0,0 +1,15 @@
node_modules
.next
.git
.gitignore
*.md
.env*
!.env.example
Dockerfile*
docker-compose*
coverage
.turbo
.vercel
npm-debug.log*
playwright-report
test-results

View File

@ -1,2 +1,4 @@
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Optional — shows Google Sign-In on /login when set
# NEXT_PUBLIC_GOOGLE_CLIENT_ID=

View File

@ -1,24 +1,28 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps || npm install --legacy-peer-deps
RUN --mount=type=cache,target=/root/.npm \
npm ci --legacy-peer-deps --prefer-offline
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL \
NODE_ENV=production
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

View File

@ -1,36 +1,20 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# frontend · nomadro
## Getting Started
Next.js App Router UI for [nomadro](https://nomadweb.nomadro.com).
First, run the development server:
**业务逻辑全部在 FastAPI**(`../backend`)。本目录只负责页面与交互。
完整架构、三环产品路径、环境变量与部署说明见仓库根目录 **[README.md](../README.md)**。
```bash
cp .env.local.example .env.local
npm install --legacy-peer-deps
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
生产构建需设置:
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
```env
NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1
NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
```

View File

@ -0,0 +1,73 @@
{
"title": "游牧代码",
"subtitle": "数字游民与 Homelab 生存指南",
"author": "林远",
"description": "一个程序员离开工位、带上电脑环游世界,同时把「家」装进机柜的故事。",
"tagline": "在任意城市,过你想要的生活。",
"coverGradient": "from-violet-950 via-fuchsia-900 to-orange-900",
"readingMinutes": 45,
"viralTags": ["数字游民", "Homelab", "远程办公", "Tailscale", "自由职业"],
"stats": [
{ "emoji": "🌍", "value": "7", "label": "章精华" },
{ "emoji": "⏱️", "value": "45", "label": "分钟读完" },
{ "emoji": "📍", "value": "3+", "label": "城市灵感" },
{ "emoji": "🔥", "value": "12", "label": "书摘片段" }
],
"hooks": [
{ "emoji": "💻", "text": "远程办公的第一周,效率奇高。第二周,你开始分不清「工作结束」和「生活开始」。" },
{ "emoji": "🌴", "text": "清迈的周二早晨:flat white + VS Code,旁边有人在写旅行博客。" },
{ "emoji": "🗄️", "text": "Homelab 给你的锚,不是地理上的,是数字上的——文件在,服务在,随时等你回来。" },
{ "emoji": "🔑", "text": "Tailscale:无论 IP 怎么变,你的设备永远在「同一个局域网」。" }
],
"chapters": [
{
"slug": "00-preface",
"title": "序:我为什么开始游牧",
"emoji": "✈️",
"hook": "我想要的不是更好的工位,而是可以选择坐在哪里。",
"shareQuote": "数字游民是一种工作方式:产出不绑定在某个地理坐标上。"
},
{
"slug": "01-first-remote",
"title": "第一次远程办公的那个周五",
"emoji": "🏠",
"hook": "穿着睡衣开 Zoom,可能是当代最魔幻的上班方式。",
"shareQuote": "异步沟通:不是「随时在线」,而是「明确预期」。"
},
{
"slug": "02-chiangmai",
"title": "清迈的周二早晨",
"emoji": "☕",
"hook": "游牧不是一个人流浪,是在流动中找到同类。",
"shareQuote": "在这里,旅游和工作本来就不矛盾。"
},
{
"slug": "03-gear",
"title": "行囊里真正重要的东西",
"emoji": "🎒",
"hook": "带多了是负担,带少了会后悔——这是游牧者的永恒命题。",
"shareQuote": "网络,比护照更重要。"
},
{
"slug": "04-homelab",
"title": "Homelab:把家装进一个机柜",
"emoji": "🖥️",
"hook": "几百块的旧电脑,可以成为整个数字生活的中枢。",
"shareQuote": "完美是优秀的敌人。先跑起来,再迭代。"
},
{
"slug": "05-network",
"title": "无论在哪,都能连回家",
"emoji": "🔗",
"hook": "人在巴厘岛,手在家里。",
"shareQuote": "Homelab 服务默认不暴露公网——这是安全感的前提。"
},
{
"slug": "06-epilogue",
"title": "终章:自由是一种日常",
"emoji": "🌅",
"hook": "自由不是不用工作,而是可以选择在哪里、以什么节奏工作。",
"shareQuote": "小步验证,比宏大计划更可靠。"
}
]
}

View File

@ -0,0 +1,31 @@
# 序:我为什么开始游牧
二〇二三年秋天,我在公司工位上坐满了三年。
显示器左边是 Jira,右边是 Slack,中间是我已经改过十七遍的需求文档。某个加班的深夜,我刷到一张清迈咖啡馆的照片——阳光从百叶窗缝里漏进来,一台 MacBook 和一杯冰美式,背景里有人在用泰语点单。
那一刻我突然意识到:**我想要的不是更好的工位,而是可以选择坐在哪里。**
---
## 这不是逃离
很多人把数字游民理解成「辞职、环游世界、发 Instagram」。不是的。
数字游民是一种**工作方式**:你的产出不绑定在某个地理坐标上。你可以在上海写代码,也可以在里斯本写代码——只要网络稳定、时区能协调、交付不掉链子。
Homelab 则是另一种自由:无论你在哪个时区醒来,你的文件、你的服务、你的数据,都在一个你完全掌控的地方等着你。
这本书写的,就是这两件事如何交织在一起。
---
## 你会读到什么
这不是技术文档,也不是旅行攻略。这是一个普通程序员,从第一次远程办公,到在东南亚租下月付公寓,再到把家里那台旧服务器变成「随时能连回去的数字基地」的真实过程。
有踩过的坑,也有意外的好运。
如果你也在想:**能不能不辞职,但换一种活法?**
那就从下一章开始吧。

View File

@ -0,0 +1,39 @@
# 第一次远程办公的那个周五
周五下午三点,Team Leader 在群里发了一条消息:
> 「下周开始,技术组全员 remote,有问题的私聊 HR。」
我盯着这条消息看了很久。不是担心,是一种奇怪的安静——像考试结束铃响之前,你已经写完了最后一道题。
---
## 第一天
周一早上九点,我穿着睡衣坐在餐桌前,打开 Zoom。
同事们的脸出现在格子里,有人还在刷牙,有人背景是乱糟糟的卧室。会议结束后,我泡了杯咖啡,走到阳台。
楼下有人在遛狗,阳光很好。
**原来工作日也可以是这样。**
---
## 效率的幻觉
远程的第一周,我效率奇高。没有通勤,没有工位旁的闲聊,没有「开完这个会再写代码」的拖延。
第二周,我开始分不清「工作结束」和「生活开始」的边界。晚上十一点,Slack 还亮着绿点。周末,邮箱里躺着三条「不急,周一前看就行」的消息。
我学到一个词:**异步沟通**。不是「随时在线」,而是「明确预期」——什么时候回复、什么算紧急、什么可以等到下一个工作日。
---
## 那个周五的后续
三个月后,HR 发邮件说办公室租约到期,技术组永久 remote。
我没有回「太好了」,也没有回「怎么办」。我只是打开地图,搜索了「清迈 月租 公寓 WiFi」。
有些改变,不是突然发生的。是你某天回头看,发现路已经走了一半。

View File

@ -0,0 +1,44 @@
# 清迈的周二早晨
七点半,我被摩托车声叫醒。
不是闹钟——是楼下送菜的小贩,喇叭里循环播放泰语,我一个字也听不懂,但已经习惯了。这是我在清迈的第三周。
---
## 咖啡馆即办公室
我住的公寓步行十分钟有一家叫 **Graph Table** 的咖啡馆。二层靠窗的位置,能看到一条安静的小巷。WiFi 密码写在收银台的小黑板上,下载速度稳定在 80 Mbps 左右。
我点一杯 flat white,打开 VS Code。
旁边的荷兰设计师在画 Figma,日本背包客在写旅行博客。没有人问「你是来旅游的还是来工作的」——在这里,这两件事本来就不矛盾。
---
## 时区的艺术
我在东七区,团队大部分人在东八区。这意味着:
- 我的「早上十点 standup」,是清迈的九点
- 他们的「下班前 review」,是我的下午五点——刚好收工去夜市
我学会把深度工作放在本地上午,把会议和沟通压缩到下午。不是妥协,是**设计**。
---
## 孤独与社群
数字游民最容易被忽视的问题,不是签证,不是网络,是**孤独**。
我在 Nomad List 的 Discord 里认识了几个常驻清迈的开发者。我们每周三晚上在 Nimman 区聚餐,聊技术、聊签证、聊哪个 co-working 空间的空调更冷。
**游牧不是一个人流浪,是在流动中找到同类。**
---
## 周二的结尾
晚上八点,我骑摩托车回公寓。路过塔佩门,游客正在拍夜景。我停了一分钟,然后继续往前。
明天还有 standup。但此刻,清迈的风是温的。

View File

@ -0,0 +1,45 @@
# 行囊里真正重要的东西
出发前,我在 Reddit 的 r/digitalnomad 翻了三天装备帖。最后发现:**带多了是负担,带少了会后悔。**
这是我在路上一年后,精简再精简的清单。
---
## 电脑:唯一不能妥协的
一台 14 寸 MacBook Pro,M 系列芯片,16GB 内存。不是最新款,但足够跑 Docker、开十几个 Chrome 标签、同时 Zoom 和写代码。
外接显示器?我试过,最后卖了。游牧的核心是**移动**,不是把工位复制到每一个城市。
---
## 网络:比护照更重要
我随身带三样东西:
1. **本地 SIM 卡** — 落地当天买,流量包月
2. **便携路由器** — GL.iNet 那类,支持 4G 备用
3. **Tailscale** — 后面会细讲,这是连回 Homelab 的钥匙
在清迈、巴厘岛、里斯本,我学会的第一件事永远是:先测 WiFi,再 unpack。
---
## 其他:能省则省
- 降噪耳机:飞机、咖啡馆、合租房必备
- 万能转换插头:一个就够
- 折叠键盘:可选,我最后只用笔记本自带
衣服?一个 40L 背包,七天的量,到哪里都能洗。
---
## 真正重要的
装备可以列清单,但有三样东西清单上写不下:
**稳定的收入、可预期的工作节奏、以及一个随时可以回去的地方。**
第三个,Homelab 会帮你解决。

View File

@ -0,0 +1,48 @@
# Homelab:把家装进一个机柜
二〇二二年,我还住在租来的单间里,床底下藏着一台 Dell OptiPlex 小主机。
当时只是想跑个 Plex,把电影串流到手机。没想到,这台几百块的机器,后来成了我整个数字生活的中枢。
---
## 什么是 Homelab
Homelab,字面意思是「家庭实验室」。简单说,就是**你自己搭的、自己管的服务器环境**。
不是云服务那种「按月订阅、数据在别人机房」。是你的 NAS、你的 Git 仓库、你的笔记同步、你的自动化脚本——物理上可能就在你国内那个出租屋里,逻辑上却跟着你走遍世界。
---
## 我的第一套配置
| 组件 | 选择 | 用途 |
|------|------|------|
| 主机 | Dell OptiPlex 7040 | 7×24 低功耗运行 |
| 系统 | Proxmox | 虚拟机管理 |
| 存储 | 4TB 机械 + 256GB SSD | 文件 + 系统 |
| 服务 | Jellyfin, Nextcloud, Gitea | 媒体、同步、代码 |
总花费不到三千块。比一台新 iPhone 便宜,但价值在于:**它是你的。**
---
## 为什么游牧者需要 Homelab
人在清迈,文件在上海的硬盘里——听起来很蠢,对吧?
但云盘有容量限制,有审查风险,有「服务条款变更」的隐患。Homelab 给你的,是**数据主权**和**不受地点限制的访问权**。
配合 Tailscale 或 WireGuard,你在任何 WiFi 下,都能像坐在家里一样访问自己的服务。
---
## 从一台旧电脑开始
你不需要一上来就买机柜、交换机、UPS。
找一台闲置笔记本,装 Linux,跑一个 Nextcloud 或 FileBrowser。能远程访问,就算 Homelab 入门了。
**完美是优秀的敌人。先跑起来,再迭代。**
下一章,我们聊怎么「连回家」。

View File

@ -0,0 +1,59 @@
# 无论在哪,都能连回家
清迈咖啡馆的 WiFi 突然断了。
我切换到手机热点,打开终端,输入:
```bash
ssh nas
```
三秒后,我出现在家里那台 Proxmox 主机的 shell 里。文件在,服务在,就像从未离开过。
靠的是 **Tailscale**。
---
## 虚拟局域网
Tailscale 基于 WireGuard,把你在不同网络的设备,组成一个加密的虚拟局域网(Tailnet)。
家里跑 Tailscale 的 NAS,笔记本跑 Tailscale 客户端——无论 IP 怎么变,它们永远在「同一个局域网」里。
配置过程:
1. 在 Homelab 主机安装 Tailscale
2. 笔记本、手机同样安装并登录同一账号
3. 用 MagicDNS 给设备起名字,比如 `nas.tail-xxxxx.ts.net`
不需要公网 IP,不需要端口转发,不需要折腾路由器。
---
## 我常用的远程场景
**同步文件** — Nextcloud 客户端走 Tailscale 内网,速度比公网快,也不暴露服务到互联网。
**看代码** — Gitea 跑在内网,浏览器打开 `http://gitea:3000`,只有 Tailnet 里的设备能访问。
**紧急修复** — 生产环境告警,SSH 进去,改配置,重启服务。人在巴厘岛,手在家里。
---
## 安全习惯
- Homelab 服务**默认不暴露公网**
- 敏感操作走 SSH 密钥,禁用密码登录
- 定期备份到另一块硬盘,异地再备一份
网络自由的前提,是**安全意识**不能掉线。
---
## 连回家,意味着什么
游牧者容易有一种漂流感:今天这条街,明天那座城,没有锚。
Homelab 给我的锚,不是地理上的,是**数字上的**——我知道有一个地方,存着我的东西,跑着我的服务,随时等我回来。
这种确定感,和清迈的咖啡一样,是/nomad 生活的基础设施。

View File

@ -0,0 +1,49 @@
# 终章:自由是一种日常
写这本书时,我人在里斯本。
公寓窗外是电车轨道,偶尔有铃声。桌上有半杯 bica,电脑屏幕里是未提交的 commit。
---
## 自由不是假期
很多人把数字游民想象成「永远在度假」。实际上,周二依然是周二,需求依然会改,bug 依然会在上线前出现。
**自由不是不用工作,而是可以选择在哪里、以什么节奏工作。**
---
## Homelab 与游牧的共生
这两件事看起来方向相反:
- 游牧是**流动**
- Homelab 是**固定**
但组合在一起,逻辑就通了:你在物理世界移动,数字世界的「家」保持不动。像蜗牛的壳,不是负担,是随身携带的秩序。
---
## 给正在考虑的你
如果你还在工位上读这本书,不必立刻辞职,不必立刻买票。
你可以:
1. 先和团队谈一次 remote 的可能性
2. 用一台旧电脑搭一个最小 Homelab
3. 选一个周末,带着电脑去邻城住两晚,测试「在别处工作」的感觉
**小步验证,比宏大计划更可靠。**
---
## 最后
游牧代码,写的不是「如何逃离」,而是「如何建造」——建造一种你真正想要的生活,并用技术和纪律去支撑它。
书到这里结束。你的下一章,在自己手里。
—— 林远
写于里斯本,一个普通的周二

View File

@ -0,0 +1,73 @@
{
"title": "Nomad Code",
"subtitle": "A Digital Nomad & Homelab Survival Guide",
"author": "Lin Yuan",
"description": "A programmer leaves the office, travels with a laptop, and packs \"home\" into a rack.",
"tagline": "Live the life you want — from any city.",
"coverGradient": "from-violet-950 via-fuchsia-900 to-orange-900",
"readingMinutes": 45,
"viralTags": ["DigitalNomad", "Homelab", "RemoteWork", "Tailscale", "Freelance"],
"stats": [
{ "emoji": "🌍", "value": "7", "label": "Chapters" },
{ "emoji": "⏱️", "value": "45", "label": "Min read" },
{ "emoji": "📍", "value": "3+", "label": "City ideas" },
{ "emoji": "🔥", "value": "12", "label": "Quotes" }
],
"hooks": [
{ "emoji": "💻", "text": "Week one of remote work: productivity soars. Week two: you can't tell when work ends and life begins." },
{ "emoji": "🌴", "text": "Tuesday morning in Chiang Mai: flat white + VS Code, someone writing a travel blog nearby." },
{ "emoji": "🗄️", "text": "Homelab is your anchor — not geographic, but digital. Files stay, services stay, waiting for you." },
{ "emoji": "🔑", "text": "Tailscale: no matter how IPs change, your devices stay on the same LAN." }
],
"chapters": [
{
"slug": "00-preface",
"title": "Preface: Why I Started Nomading",
"emoji": "✈️",
"hook": "I didn't want a better desk — I wanted to choose where I sit.",
"shareQuote": "Digital nomadism is a way of working: output isn't tied to a place."
},
{
"slug": "01-first-remote",
"title": "That First Remote Friday",
"emoji": "🏠",
"hook": "Zoom in pajamas might be the most surreal way to work today.",
"shareQuote": "Async communication: not \"always online\", but \"clear expectations\"."
},
{
"slug": "02-chiangmai",
"title": "Tuesday Morning in Chiang Mai",
"emoji": "☕",
"hook": "Nomading isn't wandering alone — it's finding your people on the move.",
"shareQuote": "Here, travel and work were never at odds."
},
{
"slug": "03-gear",
"title": "What Actually Matters in Your Bag",
"emoji": "🎒",
"hook": "Pack too much and you're burdened; too little and you'll regret it.",
"shareQuote": "Connectivity matters more than passports."
},
{
"slug": "04-homelab",
"title": "Homelab: Home in a Rack",
"emoji": "🖥️",
"hook": "An old PC can become the hub of your digital life.",
"shareQuote": "Perfect is the enemy of good. Ship first, iterate later."
},
{
"slug": "05-network",
"title": "Connect Home from Anywhere",
"emoji": "🔗",
"hook": "Body in Bali, hands at home.",
"shareQuote": "Homelab services shouldn't face the public internet — that's the baseline for safety."
},
{
"slug": "06-epilogue",
"title": "Epilogue: Freedom as a Daily Practice",
"emoji": "🌅",
"hook": "Freedom isn't not working — it's choosing where and at what pace you work.",
"shareQuote": "Small experiments beat grand plans."
}
]
}

View File

@ -0,0 +1,94 @@
{
"$schema": "./features.schema.json",
"$comment": "功能模块总开关 — 关闭任何模块不影响其余功能",
"analytics": {
"enabled": true,
"provider": "ga4",
"measurementId": ""
},
"ads": {
"enabled": false,
"provider": "adsense",
"clientId": "",
"slots": {
"cover": "",
"readerMid": "",
"readerEnd": ""
}
},
"payment": {
"crypto": {
"enabled": false,
"provider": "nowpayments",
"tiers": [
{ "amount": 5, "label": "Coffee" },
{ "amount": 10, "label": "Lunch" },
{ "amount": 20, "label": "Sponsor" }
]
},
"checkout": {
"enabled": true,
"provider": "zpay",
"buttonText": "购买下载版 ¥29",
"productName": "游牧代码 · 下载版 (PDF & EPUB)"
}
},
"feedback": {
"enabled": false,
"title": "这本书对你有帮助吗?",
"subtitle": "欢迎提建议、纠错或合作联系,我会认真看每一条。"
},
"affiliate": {
"enabled": true,
"rules": [
{
"hosts": ["amazon.com", "amzn.to", "amazon.cn"],
"param": "tag=your-amazon-tag-20"
},
{
"hosts": ["taobao.com", "tmall.com"],
"rewrite": "https://s.click.taobao.com/t?eurl={encoded}&union_lens=lensId"
}
]
},
"share": {
"enabled": true,
"campaign": "nomad-code",
"defaultHashtags": ["数字游民", "Homelab", "远程办公"],
"viralPrompt": {
"enabled": true,
"triggerPercent": 42
}
},
"reader": {
"bookmarks": { "enabled": true },
"highlights": { "enabled": true },
"search": { "enabled": true },
"progress": { "enabled": true },
"zen": { "enabled": true },
"shortcuts": { "enabled": true },
"themeToggle": { "enabled": true },
"fontControl": { "enabled": true }
},
"userdata": {
"enabled": true,
"syncToServer": false,
"$comment": "syncToServer=true 时书签/划线/进度同步到 PocketBase,否则仅 localStorage"
},
"backend": {
"pocketbase": { "enabled": true },
"email": {
"enabled": false,
"$comment": "需要配置 SMTP 环境变量才能发送购买邮件"
}
}
}

View File

@ -0,0 +1,8 @@
{
"$comment": "品牌身份信息 — 模块开关和配置见 features.json",
"brand": {
"name": "游牧代码",
"xHandle": "@linyuan",
"contactEmail": "hello@example.com"
}
}

View File

@ -2,6 +2,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
compress: true,
async rewrites() {
return [
{

File diff suppressed because it is too large Load Diff

View File

@ -4,14 +4,26 @@
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@ant-design/nextjs-registry": "^1.3.0",
"antd": "^6.6.1",
"github-slugger": "^2.0.0",
"highlight.js": "^11.9.0",
"next": "16.3.3",
"next-themes": "^0.4.4",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-dom": "19.2.8",
"react-markdown": "^9.0.3",
"rehype-highlight": "^7.0.1",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"tailwindcss": "^4.0.0"
},
"devDependencies": {
"@types/node": "^20",

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Some files were not shown because too many files have changed in this diff Show More