Compare commits
No commits in common. "dev2" and "main" have entirely different histories.
28
.gitignore
vendored
28
.gitignore
vendored
@ -6,21 +6,6 @@ backend/**/__pycache__/
|
|||||||
backend/.env
|
backend/.env
|
||||||
frontend/.env.local
|
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
|
||||||
pocketbase/pb_data/*
|
pocketbase/pb_data/*
|
||||||
!pocketbase/pb_data/.gitkeep
|
!pocketbase/pb_data/.gitkeep
|
||||||
@ -29,13 +14,6 @@ pocketbase/pb_data/*
|
|||||||
frontend/.next/
|
frontend/.next/
|
||||||
frontend/out/
|
frontend/out/
|
||||||
|
|
||||||
# Mini programs
|
|
||||||
wemini/node_modules/
|
|
||||||
wemini/dist/
|
|
||||||
dymini/node_modules/
|
|
||||||
dymini/dist/
|
|
||||||
redmini/dist/
|
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
@ -53,14 +31,8 @@ Thumbs.db
|
|||||||
scripts/remote_deploy.py
|
scripts/remote_deploy.py
|
||||||
scripts/redeploy_server.py
|
scripts/redeploy_server.py
|
||||||
scripts/upload_and_deploy.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_server.py
|
||||||
scripts/check_ports.py
|
scripts/check_ports.py
|
||||||
scripts/find_caddy.py
|
scripts/find_caddy.py
|
||||||
scripts/read_caddy.py
|
scripts/read_caddy.py
|
||||||
scripts/verify_site.py
|
scripts/verify_site.py
|
||||||
|
|
||||||
wemini/.swc/
|
|
||||||
wemini/project.private.config.json
|
|
||||||
|
|
||||||
|
|||||||
311
README.md
311
README.md
@ -1,109 +1,63 @@
|
|||||||
# nomadro · 数字游民旅居平台
|
# NomadFlow · 数字游民旅居平台
|
||||||
|
|
||||||
品牌 **nomadro** — 用一行代码环游世界 🌏
|
全栈数字游民旅居指南,采用 **Next.js + FastAPI + PocketBase** 架构。
|
||||||
|
品牌:**NomadFlow** — 用一行代码环游世界 🌏
|
||||||
**线上:** [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 | **仅 UI**;App Router、standalone |
|
| 前端 | Next.js 16 + React 19 | SSR/ISR、组件化 UI、Turbopack |
|
||||||
| 后端 | FastAPI | **全部业务逻辑**(无 Next.js 业务 API Route) |
|
| 后端 | FastAPI | REST API、业务逻辑 |
|
||||||
| 数据 | PocketBase | 主存储;本地可 Mock 降级 |
|
| 数据库 | PocketBase | 数据持久化、管理后台 |
|
||||||
| 对象存储 | S3 兼容(生产 SeaweedFS) | 媒体上传 |
|
| 图表 | Chart.js (CDN) | 数据可视化 |
|
||||||
| 部署 | Caddy + systemd | 原生进程;勿与同机 dylive 业务互相干扰 |
|
| 部署 | Vercel + Railway + Docker | 前后端分离部署 |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
nomadweb/
|
nomadweb/
|
||||||
├── frontend/ # Next.js(页面与组件)
|
├── frontend/ # Next.js 前端应用
|
||||||
│ ├── src/app/ # 路由
|
│ ├── src/app/ # 页面路由 (App Router)
|
||||||
│ ├── src/components/ # UI(含 RingNext 下一步条)
|
│ ├── src/components/ # UI 组件 (30+)
|
||||||
│ └── src/lib/ # api / auth / i18n / rings
|
│ └── src/lib/ # API 客户端、Auth、Toast
|
||||||
├── backend/ # FastAPI
|
├── backend/ # FastAPI 后端
|
||||||
│ ├── app/routers/ # REST
|
│ ├── app/routers/ # API 路由
|
||||||
│ ├── app/services/ # PocketBase、S3、城市深度等
|
│ ├── app/services/ # PocketBase、Auth 服务
|
||||||
│ └── app/data/ # Mock 降级(开发)
|
│ └── app/data/ # Mock 降级数据
|
||||||
├── wemini/ # 微信小程序(Taro → weapp,可联网)
|
├── pocketbase/ # PocketBase 数据目录
|
||||||
├── dymini/ # 抖音小程序(Taro → tt,可联网)
|
├── scripts/ # 开发启动脚本 (dev.ps1 / dev.sh)
|
||||||
├── redmini/ # 小红书 miniTool(纯前端离线,禁止联网)
|
├── docker-compose.yml # 一键启动全部服务
|
||||||
├── deploy/ # Caddy、systemd、env 模板
|
├── index.html # 原始静态版(保留)
|
||||||
├── scripts/ # 本地开发与生产更新
|
└── README.md
|
||||||
└── 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(可选)
|
### 方式一:本地开发(推荐)
|
||||||
|
|
||||||
不启动则 API 使用 Mock。
|
**1. 启动 PocketBase(可选)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 下载: https://pocketbase.io/docs/
|
||||||
|
# 或使用 Docker:
|
||||||
docker run -d -p 8090:8090 -v ./pocketbase/pb_data:/pb_data ghcr.io/muchobien/pocketbase:latest
|
docker run -d -p 8090:8090 -v ./pocketbase/pb_data:/pb_data ghcr.io/muchobien/pocketbase:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. FastAPI
|
访问 http://localhost:8090/_/ 创建管理员(可选,不启动则自动使用 Mock 数据)。
|
||||||
|
|
||||||
|
**2. 启动 FastAPI 后端**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
cp .env.example .env
|
cp .env.example .env # 按需修改
|
||||||
uvicorn app.main:app --reload --port 8000
|
uvicorn app.main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
文档:http://localhost:8000/docs
|
API 文档: http://localhost:8000/docs
|
||||||
|
|
||||||
### 3. Next.js
|
**3. 启动 Next.js 前端**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
@ -112,129 +66,176 @@ npm install --legacy-peer-deps
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
访问:http://localhost:3000
|
访问 http://localhost:3000
|
||||||
|
|
||||||
|
### 方式二:一键脚本(Windows)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\dev.ps1 # Windows 一键
|
.\scripts\dev.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
## 核心功能一览
|
### 方式三:Docker Compose
|
||||||
|
|
||||||
按环归类(深度页会互相引导,避免平铺):
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
### 探索
|
| 服务 | 地址 |
|
||||||
| 能力 | 路径 |
|
|
||||||
|------|------|
|
|------|------|
|
||||||
| 地图 / 目的地 | `/#map` `/#destinations` `/destinations/[slug]` |
|
| 前端 | http://localhost:3000 |
|
||||||
| 智能匹配 | 首页 CTA / `M` |
|
| API | http://localhost:8000/docs |
|
||||||
| 下一站决策 | `/next-stop` |
|
| PocketBase | http://localhost:8090/_/ |
|
||||||
| 城市对比 | `/compare` |
|
|
||||||
| 旅居计划 | `/plan`(时间轴、预算、签证提醒、清单、ICS) |
|
|
||||||
| 签证指南 | `/#visa` |
|
|
||||||
|
|
||||||
### 连接
|
## 功能特性
|
||||||
| 能力 | 路径 |
|
|
||||||
|
### 首页核心板块
|
||||||
|
|
||||||
|
| 板块 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 活动 RSVP / 直播间 | `/meetups` `/meetups/[id]/live` |
|
| 🗺️ 世界地图 | 交互式 SVG 热力图,点击查看城市详情 |
|
||||||
| 社区讨论 | `/community` |
|
| 🌍 目的地 | 筛选/搜索/排序,收藏,最多 4 城对比 |
|
||||||
| 游民匹配 | `/dating` |
|
| 🎯 智能匹配 | 4 步问卷,推荐最适合的旅居城市 |
|
||||||
| 私信 | `/chat` |
|
| 🕐 时区看板 | 实时时钟 + 与国内团队工作时间重叠分析 |
|
||||||
| 通知 | `/notifications` |
|
| 🗓️ 行程规划 | 多城市规划,预算汇总,分享/导入链接 |
|
||||||
|
| 🧮 费用计算器 | 按目的地、住宿档次、月数估算预算 |
|
||||||
|
| 💱 多币种换算 | CNY/USD/EUR/THB 等 8 种货币互转 |
|
||||||
|
| 📊 就绪度测评 | 5 题评估你的数字游民准备程度 |
|
||||||
|
| 💻 生活方式 | 交互式「游民一天」时间轴 |
|
||||||
|
| 📋 签证指南 | 难度筛选 + 进度条可视化 |
|
||||||
|
| 📝 博客 | 文章列表 + 阅读进度条详情页 |
|
||||||
|
| ❓ FAQ | 手风琴问答 |
|
||||||
|
|
||||||
### 成长
|
### 交互与体验
|
||||||
| 能力 | 路径 |
|
|
||||||
|
- ✨ 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`。
|
||||||
|
|
||||||
|
## 页面路由
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 游民学院 | `/digital` |
|
| `/` | 首页(全部板块) |
|
||||||
| 赏金任务 | `/gigs` |
|
| `/login` | 登录 / 注册 |
|
||||||
| 工具箱 | `/tools`(实用小工具,非功能总览) |
|
| `/profile` | 用户中心 |
|
||||||
| 电子书 | `/book` |
|
| `/destinations/[slug]` | 目的地详情(城市画像、加入行程) |
|
||||||
| 会员 | `/join` `/pricing` |
|
| `/blog/[slug]` | 博客详情(阅读进度) |
|
||||||
|
|
||||||
演示账号:`demo@nomadro.com` / `demo123`
|
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
**Backend**
|
### Backend (`backend/.env`)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
POCKETBASE_URL=http://127.0.0.1:8090
|
POCKETBASE_URL=http://127.0.0.1:8090
|
||||||
POCKETBASE_ADMIN_EMAIL=...
|
POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io
|
||||||
POCKETBASE_ADMIN_PASSWORD=...
|
POCKETBASE_ADMIN_PASSWORD=admin123456
|
||||||
CORS_ORIGINS=http://localhost:3000
|
CORS_ORIGINS=http://localhost:3000
|
||||||
# S3(生产见服务器 deploy/nomadro-api.env)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend**
|
### Frontend (`frontend/.env.local`)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
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 与部署
|
| 项目 | 地址 |
|
||||||
|
|------|------|
|
||||||
|
| 网站 | 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 |
|
||||||
|
|
||||||
仓库:
|
**服务器架构:**
|
||||||
- [gitea.dsx2020.com/eric/nomadweb](https://gitea.dsx2020.com/eric/nomadweb)(`origin`)
|
- 服务器:`107.173.30.245`
|
||||||
- [git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git)(`nomadro`)
|
- 反向代理:Caddy(自动 HTTPS)
|
||||||
|
- 前端容器:`127.0.0.1:3055` → Next.js
|
||||||
|
- 后端容器:`127.0.0.1:8055` → FastAPI
|
||||||
|
- 数据库:PocketBase(Docker 内部网络)
|
||||||
|
|
||||||
当前开发分支 **`dev2`**(亦保留 `dev1` / `main`)。
|
**更新部署:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout dev2
|
# SSH 登录服务器后
|
||||||
git pull origin dev2
|
cd /opt/nomadweb
|
||||||
# … 改代码 …
|
git pull origin main
|
||||||
git push origin dev2
|
export DOMAIN=nomadweb.nomadro.com
|
||||||
git push nomadro dev2
|
export NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1
|
||||||
python scripts/deploy_update.py # 服务器 pull + 增量构建重启
|
export CORS_ORIGINS=https://nomadweb.nomadro.com
|
||||||
|
bash scripts/deploy-production.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
| 生产进程 | 地址 |
|
或使用 Docker Compose 生产配置:
|
||||||
|----------|------|
|
|
||||||
| 网站 | https://nomadweb.nomadro.com |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
**注意:** 同机还跑有 [dylive.nomadro.com](https://dylive.nomadro.com/)(`/opt/douyinWEB`)。更新 nomadweb 时**不要**改动 / 限流 / 杀掉直播相关进程。
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
cp deploy/caddy/nomadweb.caddy /etc/caddy/sites/nomadweb.caddy
|
||||||
|
systemctl reload caddy
|
||||||
|
```
|
||||||
|
|
||||||
密钥仅放服务器 `deploy/nomadro-api.env`(模板见 `*.env.example`),勿提交。
|
### 本地 Docker Compose
|
||||||
|
|
||||||
### 微信小程序(明文)
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
| 项 | 值 |
|
### 前端 → Vercel
|
||||||
|----|-----|
|
|
||||||
| AppID | `wx11f6afc44ea1532f` |
|
|
||||||
| AppSecret | `d1814f60f900cfdee1813ccc6e00bc2d` |
|
|
||||||
|
|
||||||
- 目录:`wemini/`(`project.config.json` 已写入 AppID)
|
1. 导入 `frontend/` 目录
|
||||||
- 服务端 env:`WECHAT_APPID` / `WECHAT_SECRET` / `WECHAT_ALLOW_DEVTOOLS`(见 `deploy/nomadro-api.env.example`)
|
2. 环境变量:`NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1`
|
||||||
- 登录:`wx.login` → `POST /api/v1/auth/wechat` `{ "code": "..." }`
|
3. 自动构建部署
|
||||||
- 公众平台 request 合法域名:`nomadweb.nomadro.com`
|
|
||||||
|
### 后端 → Railway
|
||||||
|
|
||||||
|
1. 连接 `backend/` 目录
|
||||||
|
2. 配置 `POCKETBASE_URL`、`CORS_ORIGINS`
|
||||||
|
3. 读取 `railway.toml` 自动部署
|
||||||
|
|
||||||
## 快捷键
|
## 快捷键
|
||||||
|
|
||||||
| 按键 | 功能 |
|
| 按键 | 功能 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `Ctrl+K` | 全局搜索(三环捷径) |
|
| `Ctrl+K` | 全局搜索 |
|
||||||
| `M` | 智能匹配 |
|
| `M` | 智能目的地匹配 |
|
||||||
| `P` | 旅居计划 |
|
| `?` | 快捷键帮助 |
|
||||||
| `T` | 工具箱 |
|
|
||||||
| `?` | 帮助 |
|
|
||||||
| `Esc` | 关闭弹窗 |
|
| `Esc` | 关闭弹窗 |
|
||||||
|
|
||||||
## 相关
|
|
||||||
|
|
||||||
- 迭代说明:[/changelog](https://nomadweb.nomadro.com/changelog)
|
|
||||||
- 前端脚手架说明:`frontend/README.md`(以本文件为准)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
__pycache__
|
|
||||||
*.py[cod]
|
|
||||||
*.egg-info
|
|
||||||
.venv
|
|
||||||
venv
|
|
||||||
.env*
|
|
||||||
!.env.example
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
*.md
|
|
||||||
.pytest_cache
|
|
||||||
.mypy_cache
|
|
||||||
.ruff_cache
|
|
||||||
tests
|
|
||||||
@ -1,10 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
pip install -r requirements.txt
|
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@ -1,40 +1,13 @@
|
|||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
pocketbase_url: str = "http://127.0.0.1:8090"
|
pocketbase_url: str = "http://127.0.0.1:8090"
|
||||||
pocketbase_admin_email: str = "admin@nomadro.com"
|
pocketbase_admin_email: str = "admin@nomadflow.io"
|
||||||
pocketbase_admin_password: str = "Xiao4669805"
|
pocketbase_admin_password: str = "admin123456"
|
||||||
api_host: str = "0.0.0.0"
|
api_host: str = "0.0.0.0"
|
||||||
api_port: int = 8000
|
api_port: int = 8000
|
||||||
cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000"
|
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
|
@property
|
||||||
def cors_origin_list(self) -> list[str]:
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
|||||||
@ -1,343 +0,0 @@
|
|||||||
"""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",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@ -1,783 +0,0 @@
|
|||||||
"""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": "🖥️",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@ -1,210 +0,0 @@
|
|||||||
"""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",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
"""Fallback data when PocketBase is unavailable."""
|
"""Fallback data when PocketBase is unavailable."""
|
||||||
|
|
||||||
DESTINATIONS = [
|
DESTINATIONS = [
|
||||||
{
|
{
|
||||||
@ -43,48 +43,6 @@ DESTINATIONS = [
|
|||||||
"nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"],
|
"nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"],
|
||||||
"map_x": 820, "map_y": 210,
|
"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 = [
|
VISAS = [
|
||||||
@ -100,18 +58,6 @@ VISAS = [
|
|||||||
"duration": "1-4年", "income_req": "$2,500/月", "approval_time": "2-4 周", "extra": "🌮 北美时区友好", "difficulty": 30, "difficulty_label": "简单"},
|
"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",
|
{"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": "中等"},
|
"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 = [
|
FAQS = [
|
||||||
@ -133,11 +79,6 @@ TESTIMONIALS = [
|
|||||||
{"id": "1", "avatar": "👩💻", "content": "在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。", "author": "小林", "role": "前端开发 · 清迈 🇹🇭", "rating": 5},
|
{"id": "1", "avatar": "👩💻", "content": "在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。", "author": "小林", "role": "前端开发 · 清迈 🇹🇭", "rating": 5},
|
||||||
{"id": "2", "avatar": "👨🎨", "content": "里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。", "author": "Marco", "role": "UI 设计师 · 里斯本 🇵🇹", "rating": 5},
|
{"id": "2", "avatar": "👨🎨", "content": "里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。", "author": "Marco", "role": "UI 设计师 · 里斯本 🇵🇹", "rating": 5},
|
||||||
{"id": "3", "avatar": "🧑💼", "content": "带着家人做数字游民听起来疯狂,但在巴厘岛乌布,孩子们上国际学校,我和妻子远程工作,这是我们做过最正确的决定。", "author": "张家", "role": "产品经理 · 巴厘岛 🇮🇩", "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 = [
|
TOOLS = [
|
||||||
@ -150,16 +91,9 @@ TOOLS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
BLOG_POSTS = [
|
BLOG_POSTS = [
|
||||||
{"id": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "nomadro", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]},
|
{"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": "nomadro", "published_at": "2026-02-03", "read_time": 12, "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": "nomadro", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]},
|
{"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "NomadFlow", "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] = {
|
BLOG_CONTENT: dict[str, str] = {
|
||||||
@ -283,102 +217,6 @@ A: 满足居住要求后可以申请永居或入籍。""",
|
|||||||
3. 🏦 使用 Wise 等工具便于跨境汇款记录
|
3. 🏦 使用 Wise 等工具便于跨境汇款记录
|
||||||
4. 👨💼 收入超过一定金额建议聘请税务顾问
|
4. 👨💼 收入超过一定金额建议聘请税务顾问
|
||||||
5. 📱 推荐工具:Xero(记账)、TaxScouts(报税)""",
|
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 = [
|
TICKER_MESSAGES = [
|
||||||
@ -388,10 +226,4 @@ TICKER_MESSAGES = [
|
|||||||
"🏝️ 巴厘岛 Co-working 今日 89% 满座",
|
"🏝️ 巴厘岛 Co-working 今日 89% 满座",
|
||||||
"📶 清迈平均网速 95Mbps",
|
"📶 清迈平均网速 95Mbps",
|
||||||
"🌅 东京远程工作者满意度 9.1",
|
"🌅 东京远程工作者满意度 9.1",
|
||||||
"🌺 Sofia 在麦德林办完本地 SIM",
|
|
||||||
"🍷 Devon 把下一站设为第比利斯",
|
|
||||||
"🎨 Ken 报名柏林开源 Meetup",
|
|
||||||
"🏙️ Omar 从迪拜中转飞往里斯本",
|
|
||||||
"📷 Lina 在大理发布本周创作日志",
|
|
||||||
"🇰🇷 Yuki 实测首尔联合办公 220Mbps",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,312 +0,0 @@
|
|||||||
"""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": "第比利斯"},
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
"""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"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@ -1,11 +1,11 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import api, auth, community, ebook, media, meetup_auth, payment, platform, social
|
from app.routers import api, auth
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="nomadro API",
|
title="NomadFlow API",
|
||||||
description="数字游民旅居平台后端 API",
|
description="数字游民旅居平台后端 API",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
)
|
)
|
||||||
@ -20,19 +20,12 @@ app.add_middleware(
|
|||||||
|
|
||||||
app.include_router(api.router, prefix="/api/v1")
|
app.include_router(api.router, prefix="/api/v1")
|
||||||
app.include_router(auth.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("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {
|
return {
|
||||||
"name": "nomadro API",
|
"name": "NomadFlow API",
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
"health": "/api/v1/health",
|
"health": "/api/v1/health",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,12 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Query, Header
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
BlogPost, BlogPostDetail, ChartData, CompareRequest, CostCalculatorRequest,
|
BlogPost, BlogPostDetail, ChartData, CompareRequest, CostCalculatorRequest,
|
||||||
CostCalculatorResponse, Destination, Discussion, DiscussionDetail, FAQ,
|
CostCalculatorResponse, Destination, FAQ, SearchResult, StatsResponse,
|
||||||
Meetup, MeetupRsvpRequest, MeetupSession, NextStopRequest, NextStopResponse, NomadRoute,
|
|
||||||
DigitalLesson, DigitalJob,
|
|
||||||
RecommendedDestination, SearchResult, StatsResponse,
|
|
||||||
SubscribeRequest, SubscribeResponse, Testimonial, Tool, Visa,
|
SubscribeRequest, SubscribeResponse, Testimonial, Tool, Visa,
|
||||||
)
|
)
|
||||||
from app.services.pocketbase import pb
|
from app.services.pocketbase import pb
|
||||||
from app.data import mock_data
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@ -66,24 +53,6 @@ async def get_destination(slug: str):
|
|||||||
return Destination(**data)
|
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])
|
@router.post("/destinations/compare", response_model=list[Destination])
|
||||||
async def compare_destinations(body: CompareRequest):
|
async def compare_destinations(body: CompareRequest):
|
||||||
all_dests = await pb.get_destinations()
|
all_dests = await pb.get_destinations()
|
||||||
@ -244,197 +213,3 @@ async def global_search(q: str = Query(..., min_length=1)):
|
|||||||
))
|
))
|
||||||
|
|
||||||
return results[:12]
|
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"])}
|
|
||||||
|
|||||||
@ -1,44 +1,24 @@
|
|||||||
from fastapi import APIRouter, Header, HTTPException
|
from fastapi import APIRouter, Header, HTTPException
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
AuthLogin, AuthRegister, AuthResponse, Destination, GoogleLoginRequest,
|
AuthLogin, AuthRegister, AuthResponse, Destination,
|
||||||
WeChatLoginRequest,
|
|
||||||
FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile,
|
FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile,
|
||||||
UserPlanPayload,
|
|
||||||
)
|
)
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
get_favorites, get_plan, get_user_by_token,
|
DEMO_TOKEN, get_favorites, get_user_by_token,
|
||||||
login_demo, login_google, login_wechat, login_user, register_user, save_plan, toggle_favorite,
|
login_user, register_user, toggle_favorite,
|
||||||
update_profile,
|
|
||||||
)
|
)
|
||||||
from app.services.wechat import code2session
|
|
||||||
from app.services.pocketbase import pb
|
from app.services.pocketbase import pb
|
||||||
from app.services import social_store
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
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)
|
@router.post("/register", response_model=AuthResponse)
|
||||||
async def register(body: AuthRegister):
|
async def register(body: AuthRegister):
|
||||||
result = register_user(body.email, body.password, body.name)
|
result = register_user(body.email, body.password, body.name)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(400, "该邮箱已注册")
|
raise HTTPException(400, "该邮箱已注册")
|
||||||
return AuthResponse(**_with_social_seed(result))
|
return AuthResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=AuthResponse)
|
@router.post("/login", response_model=AuthResponse)
|
||||||
@ -46,7 +26,7 @@ async def login(body: AuthLogin):
|
|||||||
result = login_user(body.email, body.password)
|
result = login_user(body.email, body.password)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(401, "邮箱或密码错误")
|
raise HTTPException(401, "邮箱或密码错误")
|
||||||
return AuthResponse(**_with_social_seed(result))
|
return AuthResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserProfile)
|
@router.get("/me", response_model=UserProfile)
|
||||||
@ -58,71 +38,13 @@ async def me(authorization: str | None = Header(None)):
|
|||||||
return UserProfile(**user)
|
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)
|
@router.get("/demo", response_model=AuthResponse)
|
||||||
async def demo_login():
|
async def demo_login():
|
||||||
"""一键体验演示账号"""
|
"""一键体验演示账号"""
|
||||||
result = login_demo()
|
user = get_user_by_token(DEMO_TOKEN)
|
||||||
if not result:
|
if not user:
|
||||||
raise HTTPException(500, "演示账号不可用")
|
raise HTTPException(500, "演示账号不可用")
|
||||||
return AuthResponse(**_with_social_seed(result))
|
return AuthResponse(token=DEMO_TOKEN, user=UserProfile(**user))
|
||||||
|
|
||||||
|
|
||||||
@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)
|
@router.get("/favorites", response_model=FavoriteResponse)
|
||||||
@ -151,14 +73,11 @@ async def profile_stats(authorization: str | None = Header(None)):
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(401, "未登录")
|
raise HTTPException(401, "未登录")
|
||||||
favs = get_favorites(token)
|
favs = get_favorites(token)
|
||||||
plan = get_plan(token) or {}
|
|
||||||
cities = len(plan.get("items") or [])
|
|
||||||
levels = [(0, "🌱 新手游民"), (1, "🎒 背包客"), (2, "✈️ 飞行游民"), (3, "🌍 环球游民")]
|
levels = [(0, "🌱 新手游民"), (1, "🎒 背包客"), (2, "✈️ 飞行游民"), (3, "🌍 环球游民")]
|
||||||
explored = max(len(favs), cities)
|
level = levels[min(len(favs), 3)][1]
|
||||||
level = levels[min(explored, 3)][1]
|
|
||||||
return ProfileStats(
|
return ProfileStats(
|
||||||
favorites_count=len(favs),
|
favorites_count=len(favs),
|
||||||
destinations_explored=explored,
|
destinations_explored=len(favs),
|
||||||
member_since="2026",
|
member_since="2026",
|
||||||
nomad_level=level,
|
nomad_level=level,
|
||||||
)
|
)
|
||||||
@ -176,33 +95,6 @@ async def add_favorite(body: FavoriteRequest, authorization: str | None = Header
|
|||||||
return FavoriteResponse(slugs=slugs)
|
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:
|
def _extract_token(authorization: str | None) -> str:
|
||||||
if not authorization:
|
if not authorization:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -1,170 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
"""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)}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
"""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,
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
"""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,
|
|
||||||
}
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -156,401 +156,9 @@ class ProfileStats(BaseModel):
|
|||||||
nomad_level: str
|
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):
|
class SearchResult(BaseModel):
|
||||||
type: str # destination | blog | visa | faq
|
type: str # destination | blog | visa | faq
|
||||||
title: str
|
title: str
|
||||||
subtitle: str
|
subtitle: str
|
||||||
emoji: str
|
emoji: str
|
||||||
url: 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
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
"""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}
|
|
||||||
@ -1,174 +1,21 @@
|
|||||||
"""Auth + favorites + plans — PocketBase backed (nomad_accounts / nomad_sessions)."""
|
"""Simple auth service with in-memory fallback when PocketBase users unavailable."""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.services.pb_client import pb, pb_quote
|
from app.data import mock_data
|
||||||
from app.services.pb_repo import safe_create, safe_first, safe_update, use_pb
|
|
||||||
|
|
||||||
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "user_store.json"
|
# In-memory store for demo mode
|
||||||
|
|
||||||
# In-memory fallback when PocketBase unavailable (dev only)
|
|
||||||
_users: dict[str, dict] = {}
|
_users: dict[str, dict] = {}
|
||||||
_sessions: dict[str, str] = {}
|
_sessions: dict[str, str] = {} # token -> user_id
|
||||||
_favorites: dict[str, list[str]] = {}
|
_favorites: dict[str, list[str]] = {} # user_id -> [slugs]
|
||||||
_plans: dict[str, dict] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_password(password: str) -> str:
|
def _hash_password(password: str) -> str:
|
||||||
return hashlib.sha256(password.encode()).hexdigest()
|
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:
|
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:
|
if email in _users:
|
||||||
return None
|
return None
|
||||||
uid = secrets.token_hex(8)
|
uid = secrets.token_hex(8)
|
||||||
@ -180,117 +27,21 @@ def register_user(email: str, password: str, name: str) -> dict[str, Any] | None
|
|||||||
"avatar": "🧑💻",
|
"avatar": "🧑💻",
|
||||||
}
|
}
|
||||||
_favorites[uid] = []
|
_favorites[uid] = []
|
||||||
_plans[uid] = _empty_plan()
|
token = secrets.token_urlsafe(32)
|
||||||
_persist_json()
|
_sessions[token] = uid
|
||||||
return _issue_login(_users[email])
|
return {"token": token, "user": _user_profile(_users[email])}
|
||||||
|
|
||||||
|
|
||||||
def login_user(email: str, password: str) -> dict[str, Any] | None:
|
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)
|
user = _users.get(email)
|
||||||
if not user or user["password_hash"] != _hash_password(password):
|
if not user or user["password_hash"] != _hash_password(password):
|
||||||
return None
|
return None
|
||||||
return _issue_login(user)
|
token = secrets.token_urlsafe(32)
|
||||||
|
_sessions[token] = user["id"]
|
||||||
|
return {"token": token, "user": _user_profile(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:
|
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)
|
uid = _sessions.get(token)
|
||||||
if not uid:
|
if not uid:
|
||||||
return None
|
return None
|
||||||
@ -300,251 +51,33 @@ def get_user_by_token(token: str) -> dict | None:
|
|||||||
return 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]:
|
def get_favorites(token: str) -> list[str]:
|
||||||
uid = _token_user_id(token)
|
uid = _sessions.get(token)
|
||||||
if not uid:
|
if not uid:
|
||||||
return []
|
return []
|
||||||
if use_pb():
|
return _favorites.get(uid, [])
|
||||||
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]:
|
def toggle_favorite(token: str, slug: str) -> list[str]:
|
||||||
uid = _token_user_id(token)
|
uid = _sessions.get(token)
|
||||||
if not uid:
|
if not uid:
|
||||||
return []
|
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, [])
|
favs = _favorites.setdefault(uid, [])
|
||||||
if slug in favs:
|
if slug in favs:
|
||||||
favs.remove(slug)
|
favs.remove(slug)
|
||||||
else:
|
else:
|
||||||
favs.append(slug)
|
favs.append(slug)
|
||||||
_persist_json()
|
return favs
|
||||||
return list(favs)
|
|
||||||
|
|
||||||
|
|
||||||
def get_plan(token: str) -> dict[str, Any] | None:
|
def _user_profile(user: dict) -> dict:
|
||||||
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 {
|
return {
|
||||||
"items": plan.get("items") or [],
|
"id": user["id"],
|
||||||
"meta": plan.get("meta") or _empty_plan()["meta"],
|
"email": user["email"],
|
||||||
"updated_at": int(plan.get("updated_at") or 0),
|
"name": user["name"],
|
||||||
|
"avatar": user.get("avatar", "🧑💻"),
|
||||||
}
|
}
|
||||||
|
|
||||||
plan = _plans.get(uid) or _empty_plan()
|
# Demo account
|
||||||
return {
|
_demo = register_user("demo@nomadflow.io", "demo123", "演示用户")
|
||||||
"items": plan.get("items") or [],
|
DEMO_TOKEN = _demo["token"] if _demo else ""
|
||||||
"meta": plan.get("meta") or _empty_plan()["meta"],
|
|
||||||
"updated_at": int(plan.get("updated_at") or 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@ -1,138 +0,0 @@
|
|||||||
"""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],
|
|
||||||
}
|
|
||||||
@ -1,815 +0,0 @@
|
|||||||
"""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 {}),
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
"""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, ""
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
"""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 "")
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
"""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)}")
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import httpx
|
import httpx
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@ -26,9 +26,15 @@ class PocketBaseService:
|
|||||||
if self._token:
|
if self._token:
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
from app.services.pb_client import pb as sync_pb
|
r = await client.post(
|
||||||
if sync_pb.health_ok():
|
f"{self.base_url}/api/admins/auth-with-password",
|
||||||
self._token = sync_pb.admin_token()
|
json={
|
||||||
|
"identity": settings.pocketbase_admin_email,
|
||||||
|
"password": settings.pocketbase_admin_password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
self._token = r.json().get("token")
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@ -139,8 +145,9 @@ class PocketBaseService:
|
|||||||
|
|
||||||
result = await self.create_record("subscriptions", {"email": email})
|
result = await self.create_record("subscriptions", {"email": email})
|
||||||
if result:
|
if result:
|
||||||
return True, "已登记订阅。邮件推送开通后会同步发送。"
|
return True, "订阅成功!欢迎加入 NomadFlow 社区 🎉"
|
||||||
return False, "订阅暂时不可用,请稍后再试"
|
# fallback: always succeed in demo mode
|
||||||
|
return True, "订阅成功!欢迎加入 NomadFlow 社区 🎉"
|
||||||
|
|
||||||
def _map_destination(self, r: dict) -> dict:
|
def _map_destination(self, r: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,942 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
"""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']}"
|
|
||||||
"¤t=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
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -5,5 +5,3 @@ pydantic[email]==2.10.4
|
|||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
email-validator==2.2.0
|
email-validator==2.2.0
|
||||||
boto3==1.35.99
|
|
||||||
python-multipart==0.0.20
|
|
||||||
|
|||||||
@ -1,460 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Seed PocketBase collections with nomadro data.
|
Seed PocketBase collections with NomadFlow data.
|
||||||
Run after PocketBase is started: python seed_pocketbase.py
|
Run after PocketBase is started: python seed_pocketbase.py
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
[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
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
[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
|
|
||||||
@ -18,7 +18,7 @@ services:
|
|||||||
- "127.0.0.1:8055:8000"
|
- "127.0.0.1:8055:8000"
|
||||||
environment:
|
environment:
|
||||||
- POCKETBASE_URL=http://pocketbase:8090
|
- POCKETBASE_URL=http://pocketbase:8090
|
||||||
- POCKETBASE_ADMIN_EMAIL=${POCKETBASE_ADMIN_EMAIL:-admin@nomadro.com}
|
- POCKETBASE_ADMIN_EMAIL=${POCKETBASE_ADMIN_EMAIL:-admin@nomadflow.io}
|
||||||
- POCKETBASE_ADMIN_PASSWORD=${POCKETBASE_ADMIN_PASSWORD:-admin123456}
|
- POCKETBASE_ADMIN_PASSWORD=${POCKETBASE_ADMIN_PASSWORD:-admin123456}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-https://nomadweb.nomadro.com,http://nomadweb.nomadro.com}
|
- CORS_ORIGINS=${CORS_ORIGINS:-https://nomadweb.nomadro.com,http://nomadweb.nomadro.com}
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -29,7 +29,6 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://nomadweb.nomadro.com/api/v1}
|
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}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://nomadweb.nomadro.com}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
- POCKETBASE_URL=http://pocketbase:8090
|
- POCKETBASE_URL=http://pocketbase:8090
|
||||||
- POCKETBASE_ADMIN_EMAIL=admin@nomadro.com
|
- POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io
|
||||||
- POCKETBASE_ADMIN_PASSWORD=admin123456
|
- POCKETBASE_ADMIN_PASSWORD=admin123456
|
||||||
- CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
- CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
# 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` 并配置合法域名。
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: [['taro', { framework: 'react', ts: true, compiler: 'webpack5' }]],
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export default {
|
|
||||||
defineConstants: {
|
|
||||||
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'http://127.0.0.1:8000/api/v1'),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export default {
|
|
||||||
defineConstants: {
|
|
||||||
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"miniprogramRoot": "dist/",
|
|
||||||
"projectname": "nomadweb-dymini",
|
|
||||||
"description": "nomadro 抖音小程序",
|
|
||||||
"appid": "ttXXXXXXXX",
|
|
||||||
"setting": {
|
|
||||||
"urlCheck": false,
|
|
||||||
"es6": false,
|
|
||||||
"minified": false
|
|
||||||
},
|
|
||||||
"compileType": "microApp"
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"miniprogramRoot": "./",
|
|
||||||
"projectname": "nomadweb-dymini",
|
|
||||||
"description": "nomadro 抖音小程序",
|
|
||||||
"appid": "ttXXXXXXXX",
|
|
||||||
"setting": {
|
|
||||||
"urlCheck": false
|
|
||||||
},
|
|
||||||
"compileType": "microApp"
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
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: '我的' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
page {
|
|
||||||
background: #0c0b12;
|
|
||||||
color: #f4f0ea;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { PropsWithChildren } from 'react'
|
|
||||||
import './app.scss'
|
|
||||||
|
|
||||||
function App({ children }: PropsWithChildren) {
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export default definePageConfig({ navigationBarTitleText: '城市详情' })
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
|
|
||||||
.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; }
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export default definePageConfig({ navigationBarTitleText: '目的地' })
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
|
|
||||||
.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; }
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export default definePageConfig({ navigationBarTitleText: '首页' })
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
|
|
||||||
.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; }
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export default definePageConfig({ navigationBarTitleText: '活动' })
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
|
|
||||||
.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; }
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export default definePageConfig({ navigationBarTitleText: '我的' })
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
|
|
||||||
.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; }
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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')
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
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'
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"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
3
dymini/types/global.d.ts
vendored
@ -1,3 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.next
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
*.md
|
|
||||||
.env*
|
|
||||||
!.env.example
|
|
||||||
Dockerfile*
|
|
||||||
docker-compose*
|
|
||||||
coverage
|
|
||||||
.turbo
|
|
||||||
.vercel
|
|
||||||
npm-debug.log*
|
|
||||||
playwright-report
|
|
||||||
test-results
|
|
||||||
@ -1,4 +1,2 @@
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
# Optional — shows Google Sign-In on /login when set
|
|
||||||
# NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
|
||||||
|
|||||||
@ -1,28 +1,24 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
FROM node:20-alpine AS base
|
FROM node:20-alpine AS base
|
||||||
WORKDIR /app
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
||||||
npm ci --legacy-peer-deps --prefer-offline
|
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
ARG NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL \
|
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||||
NODE_ENV=production
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||||
&& adduser --system --uid 1001 nextjs
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|||||||
@ -1,20 +1,36 @@
|
|||||||
# frontend · nomadro
|
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).
|
||||||
|
|
||||||
Next.js App Router UI for [nomadro](https://nomadweb.nomadro.com).
|
## Getting Started
|
||||||
|
|
||||||
**业务逻辑全部在 FastAPI**(`../backend`)。本目录只负责页面与交互。
|
First, run the development server:
|
||||||
|
|
||||||
完整架构、三环产品路径、环境变量与部署说明见仓库根目录 **[README.md](../README.md)**。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.local.example .env.local
|
|
||||||
npm install --legacy-peer-deps
|
|
||||||
npm run dev
|
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.
|
||||||
|
|
||||||
```env
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1
|
|
||||||
NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
|
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.
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "小步验证,比宏大计划更可靠。"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
# 序:我为什么开始游牧
|
|
||||||
|
|
||||||
二〇二三年秋天,我在公司工位上坐满了三年。
|
|
||||||
|
|
||||||
显示器左边是 Jira,右边是 Slack,中间是我已经改过十七遍的需求文档。某个加班的深夜,我刷到一张清迈咖啡馆的照片——阳光从百叶窗缝里漏进来,一台 MacBook 和一杯冰美式,背景里有人在用泰语点单。
|
|
||||||
|
|
||||||
那一刻我突然意识到:**我想要的不是更好的工位,而是可以选择坐在哪里。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 这不是逃离
|
|
||||||
|
|
||||||
很多人把数字游民理解成「辞职、环游世界、发 Instagram」。不是的。
|
|
||||||
|
|
||||||
数字游民是一种**工作方式**:你的产出不绑定在某个地理坐标上。你可以在上海写代码,也可以在里斯本写代码——只要网络稳定、时区能协调、交付不掉链子。
|
|
||||||
|
|
||||||
Homelab 则是另一种自由:无论你在哪个时区醒来,你的文件、你的服务、你的数据,都在一个你完全掌控的地方等着你。
|
|
||||||
|
|
||||||
这本书写的,就是这两件事如何交织在一起。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 你会读到什么
|
|
||||||
|
|
||||||
这不是技术文档,也不是旅行攻略。这是一个普通程序员,从第一次远程办公,到在东南亚租下月付公寓,再到把家里那台旧服务器变成「随时能连回去的数字基地」的真实过程。
|
|
||||||
|
|
||||||
有踩过的坑,也有意外的好运。
|
|
||||||
|
|
||||||
如果你也在想:**能不能不辞职,但换一种活法?**
|
|
||||||
|
|
||||||
那就从下一章开始吧。
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
# 第一次远程办公的那个周五
|
|
||||||
|
|
||||||
周五下午三点,Team Leader 在群里发了一条消息:
|
|
||||||
|
|
||||||
> 「下周开始,技术组全员 remote,有问题的私聊 HR。」
|
|
||||||
|
|
||||||
我盯着这条消息看了很久。不是担心,是一种奇怪的安静——像考试结束铃响之前,你已经写完了最后一道题。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第一天
|
|
||||||
|
|
||||||
周一早上九点,我穿着睡衣坐在餐桌前,打开 Zoom。
|
|
||||||
|
|
||||||
同事们的脸出现在格子里,有人还在刷牙,有人背景是乱糟糟的卧室。会议结束后,我泡了杯咖啡,走到阳台。
|
|
||||||
|
|
||||||
楼下有人在遛狗,阳光很好。
|
|
||||||
|
|
||||||
**原来工作日也可以是这样。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 效率的幻觉
|
|
||||||
|
|
||||||
远程的第一周,我效率奇高。没有通勤,没有工位旁的闲聊,没有「开完这个会再写代码」的拖延。
|
|
||||||
|
|
||||||
第二周,我开始分不清「工作结束」和「生活开始」的边界。晚上十一点,Slack 还亮着绿点。周末,邮箱里躺着三条「不急,周一前看就行」的消息。
|
|
||||||
|
|
||||||
我学到一个词:**异步沟通**。不是「随时在线」,而是「明确预期」——什么时候回复、什么算紧急、什么可以等到下一个工作日。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 那个周五的后续
|
|
||||||
|
|
||||||
三个月后,HR 发邮件说办公室租约到期,技术组永久 remote。
|
|
||||||
|
|
||||||
我没有回「太好了」,也没有回「怎么办」。我只是打开地图,搜索了「清迈 月租 公寓 WiFi」。
|
|
||||||
|
|
||||||
有些改变,不是突然发生的。是你某天回头看,发现路已经走了一半。
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
# 清迈的周二早晨
|
|
||||||
|
|
||||||
七点半,我被摩托车声叫醒。
|
|
||||||
|
|
||||||
不是闹钟——是楼下送菜的小贩,喇叭里循环播放泰语,我一个字也听不懂,但已经习惯了。这是我在清迈的第三周。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 咖啡馆即办公室
|
|
||||||
|
|
||||||
我住的公寓步行十分钟有一家叫 **Graph Table** 的咖啡馆。二层靠窗的位置,能看到一条安静的小巷。WiFi 密码写在收银台的小黑板上,下载速度稳定在 80 Mbps 左右。
|
|
||||||
|
|
||||||
我点一杯 flat white,打开 VS Code。
|
|
||||||
|
|
||||||
旁边的荷兰设计师在画 Figma,日本背包客在写旅行博客。没有人问「你是来旅游的还是来工作的」——在这里,这两件事本来就不矛盾。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 时区的艺术
|
|
||||||
|
|
||||||
我在东七区,团队大部分人在东八区。这意味着:
|
|
||||||
|
|
||||||
- 我的「早上十点 standup」,是清迈的九点
|
|
||||||
- 他们的「下班前 review」,是我的下午五点——刚好收工去夜市
|
|
||||||
|
|
||||||
我学会把深度工作放在本地上午,把会议和沟通压缩到下午。不是妥协,是**设计**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 孤独与社群
|
|
||||||
|
|
||||||
数字游民最容易被忽视的问题,不是签证,不是网络,是**孤独**。
|
|
||||||
|
|
||||||
我在 Nomad List 的 Discord 里认识了几个常驻清迈的开发者。我们每周三晚上在 Nimman 区聚餐,聊技术、聊签证、聊哪个 co-working 空间的空调更冷。
|
|
||||||
|
|
||||||
**游牧不是一个人流浪,是在流动中找到同类。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 周二的结尾
|
|
||||||
|
|
||||||
晚上八点,我骑摩托车回公寓。路过塔佩门,游客正在拍夜景。我停了一分钟,然后继续往前。
|
|
||||||
|
|
||||||
明天还有 standup。但此刻,清迈的风是温的。
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
# 行囊里真正重要的东西
|
|
||||||
|
|
||||||
出发前,我在 Reddit 的 r/digitalnomad 翻了三天装备帖。最后发现:**带多了是负担,带少了会后悔。**
|
|
||||||
|
|
||||||
这是我在路上一年后,精简再精简的清单。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 电脑:唯一不能妥协的
|
|
||||||
|
|
||||||
一台 14 寸 MacBook Pro,M 系列芯片,16GB 内存。不是最新款,但足够跑 Docker、开十几个 Chrome 标签、同时 Zoom 和写代码。
|
|
||||||
|
|
||||||
外接显示器?我试过,最后卖了。游牧的核心是**移动**,不是把工位复制到每一个城市。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 网络:比护照更重要
|
|
||||||
|
|
||||||
我随身带三样东西:
|
|
||||||
|
|
||||||
1. **本地 SIM 卡** — 落地当天买,流量包月
|
|
||||||
2. **便携路由器** — GL.iNet 那类,支持 4G 备用
|
|
||||||
3. **Tailscale** — 后面会细讲,这是连回 Homelab 的钥匙
|
|
||||||
|
|
||||||
在清迈、巴厘岛、里斯本,我学会的第一件事永远是:先测 WiFi,再 unpack。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 其他:能省则省
|
|
||||||
|
|
||||||
- 降噪耳机:飞机、咖啡馆、合租房必备
|
|
||||||
- 万能转换插头:一个就够
|
|
||||||
- 折叠键盘:可选,我最后只用笔记本自带
|
|
||||||
|
|
||||||
衣服?一个 40L 背包,七天的量,到哪里都能洗。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 真正重要的
|
|
||||||
|
|
||||||
装备可以列清单,但有三样东西清单上写不下:
|
|
||||||
|
|
||||||
**稳定的收入、可预期的工作节奏、以及一个随时可以回去的地方。**
|
|
||||||
|
|
||||||
第三个,Homelab 会帮你解决。
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
# 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 入门了。
|
|
||||||
|
|
||||||
**完美是优秀的敌人。先跑起来,再迭代。**
|
|
||||||
|
|
||||||
下一章,我们聊怎么「连回家」。
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
# 无论在哪,都能连回家
|
|
||||||
|
|
||||||
清迈咖啡馆的 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 生活的基础设施。
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
# 终章:自由是一种日常
|
|
||||||
|
|
||||||
写这本书时,我人在里斯本。
|
|
||||||
|
|
||||||
公寓窗外是电车轨道,偶尔有铃声。桌上有半杯 bica,电脑屏幕里是未提交的 commit。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 自由不是假期
|
|
||||||
|
|
||||||
很多人把数字游民想象成「永远在度假」。实际上,周二依然是周二,需求依然会改,bug 依然会在上线前出现。
|
|
||||||
|
|
||||||
**自由不是不用工作,而是可以选择在哪里、以什么节奏工作。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Homelab 与游牧的共生
|
|
||||||
|
|
||||||
这两件事看起来方向相反:
|
|
||||||
|
|
||||||
- 游牧是**流动**
|
|
||||||
- Homelab 是**固定**
|
|
||||||
|
|
||||||
但组合在一起,逻辑就通了:你在物理世界移动,数字世界的「家」保持不动。像蜗牛的壳,不是负担,是随身携带的秩序。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 给正在考虑的你
|
|
||||||
|
|
||||||
如果你还在工位上读这本书,不必立刻辞职,不必立刻买票。
|
|
||||||
|
|
||||||
你可以:
|
|
||||||
|
|
||||||
1. 先和团队谈一次 remote 的可能性
|
|
||||||
2. 用一台旧电脑搭一个最小 Homelab
|
|
||||||
3. 选一个周末,带着电脑去邻城住两晚,测试「在别处工作」的感觉
|
|
||||||
|
|
||||||
**小步验证,比宏大计划更可靠。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 最后
|
|
||||||
|
|
||||||
游牧代码,写的不是「如何逃离」,而是「如何建造」——建造一种你真正想要的生活,并用技术和纪律去支撑它。
|
|
||||||
|
|
||||||
书到这里结束。你的下一章,在自己手里。
|
|
||||||
|
|
||||||
—— 林远
|
|
||||||
写于里斯本,一个普通的周二
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
{
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
{
|
|
||||||
"$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 环境变量才能发送购买邮件"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$comment": "品牌身份信息 — 模块开关和配置见 features.json",
|
|
||||||
"brand": {
|
|
||||||
"name": "游牧代码",
|
|
||||||
"xHandle": "@linyuan",
|
|
||||||
"contactEmail": "hello@example.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,8 +2,6 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
poweredByHeader: false,
|
|
||||||
compress: true,
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
3263
frontend/package-lock.json
generated
3263
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,26 +4,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build --webpack",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "16.3.3",
|
||||||
"next-themes": "^0.4.4",
|
|
||||||
"react": "19.2.8",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user