This commit is contained in:
eric
2026-06-06 04:29:46 -05:00
parent 88aa96a2a1
commit 41493c35ca
50 changed files with 4666 additions and 416 deletions

7
backend/.env.example Normal file
View File

@@ -0,0 +1,7 @@
PB_URL=http://127.0.0.1:8090
PB_ADMIN_EMAIL=admin@nomadcna.local
PB_ADMIN_PASSWORD=NomadCNA123456
API_COOKIE_NAME=pb_session
FRONTEND_ORIGINS=http://localhost:3001,http://127.0.0.1:3001
UPLOAD_DIR=backend/uploads
DEV_AUTO_PAY=true

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""NomadCNA local FastAPI backend."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

180
backend/enhanced_seed.py Normal file
View File

@@ -0,0 +1,180 @@
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
from .seed_data import CITIES
PERSON_NAMES = [
"林晓雨",
"陈浩然",
"王思琪",
"张明远",
"李雅雯",
"刘子轩",
"赵可",
"周雨桐",
"吴俊杰",
"郑诗涵",
"孙宇航",
"许念",
]
ROLES = ["产品设计师", "独立开发者", "内容创作者", "远程运营", "自由摄影师", "跨境电商", "AI 创业者", "用户研究员"]
def _future_date(index: int) -> str:
return (date(2026, 7, 10) + timedelta(days=index * 3)).strftime("%Y-%m-%d")
ENHANCED_MEETUPS: list[dict[str, Any]] = [
{
"city": city["name"],
"emoji": city.get("icon", "📍"),
"date": _future_date(idx),
"time": "19:00" if idx % 2 else "15:00",
"venue": f"{city['name']}远程工作者周会",
"address": f"{city['name']}核心生活区共享空间",
"description": f"围绕{city['name']}的租房、办公、社群和长期停留经验做一次小规模圆桌交流。",
"rsvpCount": max(6, int(city["nomadsNow"] * 0.025)),
"gradientFrom": city["gradientFrom"],
"gradientTo": city["gradientTo"],
"attendees": [
{"name": PERSON_NAMES[idx % len(PERSON_NAMES)], "photo": f"https://i.pravatar.cc/150?img={(idx % 60) + 1}"},
{"name": PERSON_NAMES[(idx + 3) % len(PERSON_NAMES)], "photo": f"https://i.pravatar.cc/150?img={((idx + 3) % 60) + 1}"},
],
"isUpcoming": idx % 5 != 0,
"status": "published",
}
for idx, city in enumerate(CITIES, start=1)
]
ENHANCED_DISCUSSIONS: list[dict[str, Any]] = [
{
"title": f"{city['name']}远程办公和短租落地经验汇总",
"author": PERSON_NAMES[idx % len(PERSON_NAMES)],
"city": city["name"],
"tags": [city["name"], "落地指南", "远程工作"],
"replies": 8 + idx * 2,
"views": 600 + idx * 137,
"excerpt": f"整理{city['name']}适合视频会议的住区、稳定网络和本地社群入口。",
}
for idx, city in enumerate(CITIES, start=1)
]
ENHANCED_GIGS: list[dict[str, Any]] = [
{
"title": f"补充{city['name']}数字游民城市资料",
"category": ["内容", "调研", "摄影", "活动"][idx % 4],
"budget": 300 + (idx % 6) * 200,
"location": city["name"] if idx % 3 == 0 else "远程",
"description": f"提交{city['name']}共享办公、短租、公交通勤和社群活动信息,要求有来源和照片。",
"status": "open",
}
for idx, city in enumerate(CITIES, start=1)
]
ENHANCED_PROFILES: list[dict[str, Any]] = [
{
"name": PERSON_NAMES[idx % len(PERSON_NAMES)],
"age": 24 + (idx % 13),
"location": city["name"],
"tags": [ROLES[idx % len(ROLES)], "数字游民", city["name"], "可线下见面"],
"bio": f"目前在{city['name']}远程工作,关注高质量生活、稳定产出和本地社群连接。",
"photo": f"https://i.pravatar.cc/150?img={(idx % 60) + 1}",
}
for idx, city in enumerate(CITIES, start=1)
]
SERVICES: list[dict[str, Any]] = [
{
"slug": "remote-health-insurance",
"title": "远程工作者医疗与旅行保障",
"titleEn": "Remote Health & Travel Coverage",
"icon": "🛡️",
"description": "覆盖国内旅居、短期出境、设备丢失和紧急医疗咨询,适合频繁换城市的远程工作者。",
"descriptionEn": "Coverage for domestic stays, short overseas trips, device loss, and urgent medical advice.",
"features": ["国内外医疗协助", "设备丢失补偿", "7x24 中文支持", "城市风险提醒"],
"price": 15,
"priceLabel": "¥15/天起",
"category": "保障",
"status": "published",
"gradientFrom": "#10b981",
"gradientTo": "#0f766e",
},
{
"slug": "coworking-pass",
"title": "共享办公通行证",
"titleEn": "Coworking Pass",
"icon": "💻",
"description": "为热门城市整理可视频会议、可日租、可社群活动的空间,并提供预订入口。",
"descriptionEn": "Workspace list and booking entry for day-pass and video-call friendly spaces.",
"features": ["城市空间清单", "日租/月租筛选", "视频会议友好", "会员折扣"],
"price": 99,
"priceLabel": "¥99/月起",
"category": "办公",
"status": "published",
"gradientFrom": "#2563eb",
"gradientTo": "#0891b2",
},
{
"slug": "landing-concierge",
"title": "城市落地顾问",
"titleEn": "City Landing Concierge",
"icon": "🧭",
"description": "帮你把预算、气候、网络、租房、社群入口整理成 30 天落地清单。",
"descriptionEn": "A 30-day landing checklist based on budget, climate, internet, housing, and community.",
"features": ["落地清单", "短租建议", "同城活动", "社群引荐"],
"price": 299,
"priceLabel": "¥299/次",
"category": "落地",
"status": "published",
"gradientFrom": "#f97316",
"gradientTo": "#ef4444",
},
]
ROUTES: list[dict[str, Any]] = [
{
"slug": "yunnan-slow-life",
"title": "云南慢生活线",
"titleEn": "Yunnan Slow Life",
"citySlugs": ["dali", "lijiang", "kunming"],
"durationDays": 21,
"budget": 12000,
"description": "适合内容创作者、设计师和希望低成本试住的远程工作者。",
"descriptionEn": "For creators and designers seeking a slower, lower-cost stay.",
"stops": ["大理 10 天", "丽江 5 天", "昆明 6 天"],
"status": "published",
},
{
"slug": "bay-area-builder",
"title": "湾区创业线",
"titleEn": "Bay Area Builder Route",
"citySlugs": ["shenzhen", "guangzhou", "zhuhai"],
"durationDays": 14,
"budget": 16000,
"description": "适合创业者、硬件团队和需要商务连接的远程工作者。",
"descriptionEn": "For startup builders and teams needing business access.",
"stops": ["深圳 7 天", "广州 4 天", "珠海 3 天"],
"status": "published",
},
{
"slug": "jiangnan-internet",
"title": "江南互联网线",
"titleEn": "Jiangnan Internet Route",
"citySlugs": ["hangzhou", "shanghai", "suzhou", "nanjing"],
"durationDays": 18,
"budget": 18000,
"description": "适合产品、运营、增长、AI 和跨境团队做城市比较。",
"descriptionEn": "For product, growth, AI, and cross-border teams comparing cities.",
"stops": ["杭州 6 天", "上海 5 天", "苏州 3 天", "南京 4 天"],
"status": "published",
},
]

325
backend/init_db.py Normal file
View File

@@ -0,0 +1,325 @@
from __future__ import annotations
import asyncio
from typing import Any
from .pb_client import PocketBaseError, pb
from .seed_data import (
CITIES,
CITY_DETAILS,
CONTENT_ITEMS,
DISCUSSIONS,
GIGS,
MEETUPS,
PROFILES,
)
from .enhanced_seed import ENHANCED_DISCUSSIONS, ENHANCED_GIGS, ENHANCED_MEETUPS, ENHANCED_PROFILES, ROUTES, SERVICES
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, *, required: bool = False, only_int: bool = False) -> dict[str, Any]:
return {"name": name, "type": "number", "required": required, "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]]] = {
"cities": [
text("slug", required=True, max=80),
number("idNumber", only_int=True),
text("name", required=True, max=120),
text("nameEn", max=120),
text("country", max=80),
text("countryEmoji", max=16),
number("overallScore"),
number("costPerMonth", only_int=True),
number("internetSpeed", only_int=True),
text("safety", max=40),
text("liked", max=40),
number("temperature", only_int=True),
number("humidity", only_int=True),
number("nomadsNow", only_int=True),
text("description", max=500),
text("descriptionEn", max=500),
text("gradientFrom", max=32),
text("gradientTo", max=32),
text("icon", max=16),
json_field("tags"),
text("coverImage", max=500),
number("reviewsCount", only_int=True),
number("likedCount", only_int=True),
number("dislikedCount", only_int=True),
text("qualityOfLife", max=40),
text("familyScore", max=40),
text("communityScore", max=40),
number("nomadPercent"),
text("funLabel", max=40),
number("feelsLikeTemp", only_int=True),
number("acPercent", only_int=True),
number("lat"),
number("lng"),
text("region", max=80),
],
"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),
boolean("needsPasswordChange"),
],
"member_applications": [
text("userId", max=80),
text("email", max=255),
text("nickname", max=140),
text("gender", max=40),
text("single", max=40),
text("profession", max=140),
text("intro", max=1000),
text("wechat", max=140),
text("education", max=80),
text("graduationYear", max=20),
text("phone", max=40),
text("media", max=500),
boolean("isComplete"),
],
"memberships": [
text("userId", required=True, max=80),
text("email", max=255),
text("plan", max=40),
text("status", max=40),
date("expiresAt"),
],
"orders": [
text("orderId", required=True, max=80),
text("userId", max=120),
text("email", max=255),
number("totalFee", only_int=True),
text("type", max=60),
text("channel", max=40),
text("status", max=40),
text("returnUrl", max=500),
boolean("isNew"),
],
"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),
],
"meetups": [
text("city", required=True, max=120),
text("emoji", max=16),
date("date"),
text("time", max=20),
text("venue", max=255),
text("address", max=255),
text("description", max=1000),
number("rsvpCount", only_int=True),
text("gradientFrom", max=32),
text("gradientTo", max=32),
json_field("attendees"),
boolean("isUpcoming"),
text("status", max=40),
],
"discussions": [
text("title", required=True, max=255),
text("author", max=120),
text("city", max=120),
json_field("tags"),
text("excerpt", max=1000),
number("replies", only_int=True),
number("views", only_int=True),
],
"gigs": [
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),
],
"gig_applications": [
text("gigId", required=True, max=80),
text("userId", max=80),
text("applicant", max=255),
text("message", max=1000),
text("status", max=40),
],
"services": [
text("slug", required=True, max=120),
text("title", required=True, max=255),
text("titleEn", max=255),
text("icon", max=16),
text("description", max=1000),
text("descriptionEn", max=1000),
json_field("features"),
number("price", only_int=True),
text("priceLabel", max=120),
text("category", max=80),
text("status", max=40),
text("gradientFrom", max=32),
text("gradientTo", max=32),
],
"service_leads": [
text("serviceSlug", required=True, max=120),
text("userId", max=80),
text("email", max=255),
text("note", max=1000),
text("status", max=40),
],
"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),
],
"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"),
],
"feedback": [
text("type", max=40),
text("email", max=255),
text("title", max=255),
text("content", max=2000),
text("status", max=40),
],
"swipes": [text("userId", max=80), text("profileId", max=80), text("action", max=40)],
"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("subtitleEn", max=500),
text("description", max=2000),
text("descriptionEn", max=2000),
text("coverImage", max=500),
text("mediaUrl", max=500),
text("targetUrl", max=500),
text("ctaLabel", max=120),
text("ctaLabelEn", max=120),
json_field("citySlugs"),
json_field("relatedProfiles"),
json_field("chapters"),
number("durationMinutes", only_int=True),
number("sortOrder", only_int=True),
text("status", max=40),
],
"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"),
],
}
async def collection_exists(name: str) -> bool:
try:
await pb.request("GET", f"/api/collections/{name}", admin=True)
return True
except PocketBaseError:
return False
async def create_collection(name: str, fields: list[dict[str, Any]]) -> None:
if await collection_exists(name):
print(f"collection exists: {name}")
return
payload = {
"name": name,
"type": "base",
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"fields": fields,
}
await pb.request("POST", "/api/collections", admin=True, json=payload)
print(f"collection created: {name}")
async def seed_if_empty(collection: str, records: list[dict[str, Any]]) -> None:
data = await pb.list_records(collection, per_page=1)
if data.get("totalItems", 0) > 0:
print(f"seed skipped: {collection}")
return
for record in records:
payload = dict(record)
if collection == "meetups" and payload.get("date"):
payload["date"] = f"{payload['date']} 00:00:00.000Z"
payload.setdefault("status", "published")
await pb.create_record(collection, payload)
print(f"seeded {collection}: {len(records)} records")
async def upsert_seed(collection: str, key: str, records: list[dict[str, Any]]) -> None:
count = 0
for record in records:
payload = dict(record)
if collection == "meetups" and payload.get("date") and "T" not in str(payload["date"]):
payload["date"] = f"{payload['date']} 00:00:00.000Z"
payload.setdefault("status", "published")
await pb.upsert_by_field(collection, key, str(payload[key]), payload)
count += 1
print(f"upserted {collection}: {count} records")
async def main() -> None:
for name, fields in COLLECTIONS.items():
await create_collection(name, fields)
await upsert_seed("cities", "slug", CITIES)
await seed_if_empty("meetups", MEETUPS)
await seed_if_empty("discussions", DISCUSSIONS)
await seed_if_empty("gigs", GIGS)
await seed_if_empty("profiles", PROFILES)
await upsert_seed("meetups", "venue", ENHANCED_MEETUPS)
await upsert_seed("discussions", "title", ENHANCED_DISCUSSIONS)
await upsert_seed("gigs", "title", ENHANCED_GIGS)
await upsert_seed("profiles", "name", ENHANCED_PROFILES)
await upsert_seed("content_items", "slug", CONTENT_ITEMS)
await upsert_seed("city_details", "citySlug", CITY_DETAILS)
await upsert_seed("services", "slug", SERVICES)
await upsert_seed("routes", "slug", ROUTES)
if __name__ == "__main__":
asyncio.run(main())

883
backend/main.py Normal file
View File

@@ -0,0 +1,883 @@
from __future__ import annotations
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.responses import Response as FastAPIResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from .pb_client import PocketBaseError, pb, pb_quote
from .settings import get_settings
settings = get_settings()
settings.upload_dir.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="NomadCNA Local API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.frontend_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
class AuthPayload(BaseModel):
email: str
password: str
passwordConfirm: str | None = None
class SyncSessionPayload(BaseModel):
token: str
record: dict[str, Any]
class EnsureUserPayload(BaseModel):
email: str
password: str | None = None
class CheckUserPayload(BaseModel):
email: str
class CompleteOrderPayload(BaseModel):
order_id: str
class JoinPayload(BaseModel):
nickname: str = ""
gender: str = ""
single: str = ""
profession: str = ""
intro: str = ""
wechat: str = ""
education: str = ""
graduationYear: str = ""
phone: str = ""
media: str | None = None
class ChangePasswordPayload(BaseModel):
newPassword: str
passwordConfirm: str
class FeedbackPayload(BaseModel):
type: str
email: str = ""
title: str
content: str
class SwipePayload(BaseModel):
profileId: str
action: str
class DiscussionPayload(BaseModel):
title: str
author: str = "NomadCNA"
city: str = ""
tags: list[str] = []
excerpt: str = ""
class GigPayload(BaseModel):
title: str
category: str = "任务"
budget: int = 300
location: str = "远程"
description: str = ""
class ServiceLeadPayload(BaseModel):
serviceSlug: str
email: str = ""
note: str = ""
class RecommendationPayload(BaseModel):
budget: int = 6000
internet: int = 50
climate: str = "any"
tags: list[str] = []
limit: int = 12
def set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
settings.cookie_name,
token,
httponly=True,
samesite="lax",
secure=False,
max_age=60 * 60 * 24 * 30,
path="/",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(settings.cookie_name, path="/")
def token_from_request(request: Request) -> str | None:
auth = request.headers.get("authorization", "")
if auth.lower().startswith("bearer "):
return auth.split(" ", 1)[1].strip()
return request.cookies.get(settings.cookie_name)
async def current_user(request: Request) -> tuple[str, dict[str, Any]] | tuple[None, None]:
token = token_from_request(request)
if not token:
return None, None
try:
data = await pb.request("POST", "/api/collections/users/auth-refresh", token=token)
return data.get("token") or token, data.get("record")
except Exception:
return None, None
async def find_user_by_email(email: str) -> dict[str, Any] | None:
return await pb.first_record("users", filter=f"email={pb_quote(email.lower())}")
async def find_membership(user_id: str) -> dict[str, Any] | None:
return await pb.first_record(
"memberships",
filter=f'userId={pb_quote(user_id)} && status="active"',
)
async def ensure_user(email: str, password: str | None = None) -> tuple[dict[str, Any], str, bool]:
normalized = email.lower().strip()
if "@" not in normalized or "." not in normalized.split("@")[-1]:
raise HTTPException(status_code=400, detail="请输入有效邮箱")
existing = await find_user_by_email(normalized)
is_new = False
if existing:
login_password = password or "12345678"
try:
auth = await pb.request(
"POST",
"/api/collections/users/auth-with-password",
json={"identity": normalized, "password": login_password},
)
except Exception:
if not password:
raise HTTPException(status_code=401, detail="请输入该邮箱的登录密码")
raise HTTPException(status_code=401, detail="邮箱或密码不正确")
return auth["record"], auth["token"], is_new
user_password = password or "12345678"
created = await pb.create_record(
"users",
{
"email": normalized,
"emailVisibility": True,
"verified": True,
"password": user_password,
"passwordConfirm": user_password,
"name": normalized.split("@")[0],
},
)
is_new = True
await pb.create_record(
"profiles",
{
"userId": created["id"],
"email": normalized,
"name": normalized.split("@")[0],
"age": 0,
"location": "",
"tags": ["新成员"],
"bio": "",
"photo": f"https://api.dicebear.com/7.x/initials/svg?seed={normalized}",
"needsPasswordChange": password is None,
},
)
auth = await pb.request(
"POST",
"/api/collections/users/auth-with-password",
json={"identity": normalized, "password": user_password},
)
return auth["record"], auth["token"], is_new
def public_record(record: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in record.items() if not k.startswith("@")}
@app.get("/api/health")
async def health() -> dict[str, Any]:
return {"ok": True, "service": "NomadCNA FastAPI", "pb": settings.pb_url}
@app.post("/api/auth/register")
async def register(payload: AuthPayload, response: Response) -> dict[str, Any]:
if len(payload.password) < 8:
raise HTTPException(status_code=400, detail="密码至少 8 位")
if payload.passwordConfirm and payload.password != payload.passwordConfirm:
raise HTTPException(status_code=400, detail="两次密码不一致")
try:
user, token, _ = await ensure_user(payload.email, payload.password)
except PocketBaseError as exc:
raise HTTPException(status_code=400, detail=str(exc))
set_session_cookie(response, token)
return {"ok": True, "token": token, "record": user, "user": user}
@app.post("/api/auth/login")
async def login(payload: AuthPayload, response: Response) -> dict[str, Any]:
try:
auth = await pb.request(
"POST",
"/api/collections/users/auth-with-password",
json={"identity": payload.email.lower(), "password": payload.password},
)
except Exception:
raise HTTPException(status_code=401, detail="邮箱或密码不正确")
set_session_cookie(response, auth["token"])
return {"ok": True, "token": auth["token"], "record": auth["record"], "user": auth["record"]}
@app.post("/api/auth/sync-session")
async def sync_session(payload: SyncSessionPayload, response: Response) -> dict[str, Any]:
set_session_cookie(response, payload.token)
return {"ok": True}
@app.get("/api/auth/me")
async def me(request: Request, response: Response) -> dict[str, Any]:
token, user = await current_user(request)
if not user:
return {"ok": True, "user": None}
set_session_cookie(response, token)
vip = bool(await find_membership(user["id"]))
return {"ok": True, "token": token, "user": {**user, "vip": vip}}
@app.post("/api/auth/logout")
async def logout(response: Response) -> dict[str, Any]:
clear_session_cookie(response)
return {"ok": True}
@app.post("/api/auth/change-password")
async def change_password(payload: ChangePasswordPayload, request: Request) -> dict[str, Any]:
token, user = await current_user(request)
if not user:
raise HTTPException(status_code=401, detail="未登录")
if len(payload.newPassword) < 8:
raise HTTPException(status_code=400, detail="密码至少 8 位")
if payload.newPassword != payload.passwordConfirm:
raise HTTPException(status_code=400, detail="两次密码不一致")
await pb.update_record(
"users",
user["id"],
{"password": payload.newPassword, "passwordConfirm": payload.passwordConfirm},
)
profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}')
if profile:
await pb.update_record("profiles", profile["id"], {"needsPasswordChange": False})
return {"ok": True}
@app.post("/api/meetup/check-user")
async def check_user(payload: CheckUserPayload) -> dict[str, Any]:
user = await find_user_by_email(str(payload.email))
vip = bool(await find_membership(user["id"])) if user else False
return {"ok": True, "exists": bool(user), "vip": vip, "user_id": user["id"] if user else None}
@app.post("/api/meetup/ensure-user")
async def ensure_user_endpoint(payload: EnsureUserPayload, response: Response) -> dict[str, Any]:
user, token, is_new = await ensure_user(str(payload.email), payload.password)
set_session_cookie(response, token)
vip = bool(await find_membership(user["id"]))
return {"ok": True, "user_id": user["id"], "record": user, "token": token, "is_new": is_new, "vip": vip}
@app.post("/api/meetup/set-needs-password-change")
async def set_needs_password_change(request: Request) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
raise HTTPException(status_code=401, detail="未登录")
profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}')
if profile:
await pb.update_record("profiles", profile["id"], {"needsPasswordChange": True})
return {"ok": True}
@app.get("/api/meetup/need-password-change")
async def need_password_change(request: Request) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
return {"ok": True, "need": False}
profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}')
return {"ok": True, "need": bool(profile and profile.get("needsPasswordChange"))}
@app.post("/api/meetup/complete-order")
async def complete_order(payload: CompleteOrderPayload, response: Response) -> dict[str, Any]:
order = await pb.first_record("orders", filter=f'orderId={pb_quote(payload.order_id)}')
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
if order.get("status") != "paid":
await pb.update_record("orders", order["id"], {"status": "paid"})
user_id = order.get("userId", "")
user = None
if user_id and not user_id.startswith("pending_"):
user = await pb.first_record("users", filter=f'id={pb_quote(user_id)}')
if not user:
email = order.get("email") or f"{payload.order_id}@nomadcna.local"
user, token, is_new = await ensure_user(email)
else:
auth = await pb.request(
"POST",
"/api/collections/users/auth-with-password",
json={"identity": user["email"], "password": "12345678"},
)
token = auth["token"]
is_new = False
expires = (datetime.now(timezone.utc) + timedelta(days=365)).strftime("%Y-%m-%d %H:%M:%S.000Z")
membership = await pb.first_record("memberships", filter=f'userId={pb_quote(user["id"])}')
payload_membership = {
"userId": user["id"],
"email": user.get("email", ""),
"plan": "pro",
"status": "active",
"expiresAt": expires,
}
if membership:
await pb.update_record("memberships", membership["id"], payload_membership)
else:
await pb.create_record("memberships", payload_membership)
set_session_cookie(response, token)
return {"ok": True, "token": token, "record": user, "is_new": is_new}
@app.get("/api/vip/check")
async def vip_check(request: Request) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
return {"ok": True, "vip": False}
membership = await find_membership(user["id"])
return {"ok": True, "vip": bool(membership), "membership": membership}
@app.post("/api/join")
async def join(payload: JoinPayload, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
email = user.get("email", "")
record_payload = {
**payload.model_dump(),
"userId": user["id"],
"email": email,
"isComplete": True,
}
existing = await pb.first_record("member_applications", filter=f'userId={pb_quote(user["id"])}')
if existing:
record = await pb.update_record("member_applications", existing["id"], record_payload)
else:
record = await pb.create_record("member_applications", record_payload)
profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}')
profile_payload = {
"userId": user["id"],
"email": email,
"name": payload.nickname or user.get("name") or email.split("@")[0],
"location": "",
"tags": [payload.profession, payload.education, "数字游民"],
"bio": payload.intro,
"photo": payload.media or f"https://api.dicebear.com/7.x/initials/svg?seed={email}",
}
if profile:
await pb.update_record("profiles", profile["id"], profile_payload)
else:
await pb.create_record("profiles", profile_payload)
return {"ok": True, "record": record, "user_id": user["id"]}
@app.get("/api/join/status")
async def join_status(request: Request) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
return {"ok": True, "hasRecord": False, "isComplete": False}
record = await pb.first_record("member_applications", filter=f'userId={pb_quote(user["id"])}')
return {"ok": True, "hasRecord": bool(record), "isComplete": bool(record and record.get("isComplete"))}
@app.post("/api/upload-media")
async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[str, Any]:
if file.size and file.size > 20 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过 20MB")
suffix = Path(file.filename or "upload.bin").suffix
name = f"{uuid.uuid4().hex}{suffix}"
target = settings.upload_dir / name
content = await file.read()
target.write_bytes(content)
url = f"http://127.0.0.1:8000/uploads/{name}"
user_id = ""
_, user = await current_user(request)
user_id = user["id"] if user else ""
await pb.create_record(
"media_assets",
{
"userId": user_id,
"url": url,
"filename": file.filename or name,
"contentType": file.content_type or "application/octet-stream",
"size": len(content),
},
)
return {"ok": True, "url": url}
@app.post("/api/pay", response_model=None)
async def create_pay(
response: Response,
user_id: str = Form(...),
return_url: str = Form(...),
total_fee: int = Form(100),
type: str = Form("meetup"),
channel: str = Form("wxpay"),
device: str = Form("pc"),
html: str = Form("1"),
) -> HTMLResponse | dict[str, Any]:
order_id = f"local_{uuid.uuid4().hex[:16]}"
email = ""
if user_id.startswith("pending_"):
try:
raw_hex = user_id.removeprefix("pending_")
email = bytes.fromhex(raw_hex).decode("utf-8")
except Exception:
email = ""
await pb.create_record(
"orders",
{
"orderId": order_id,
"userId": user_id,
"email": email,
"totalFee": total_fee,
"type": type,
"channel": channel,
"status": "paid" if settings.dev_auto_pay else "pending",
"returnUrl": return_url,
"isNew": user_id.startswith("pending_"),
},
)
cookie_value = f"{order_id}|{int(datetime.now().timestamp() * 1000)}"
response.set_cookie("join_pay_order", cookie_value, path="/", max_age=900)
if html == "1":
redirect_url = f"{return_url}{'&' if '?' in return_url else '?'}order_id={order_id}"
html_response = HTMLResponse(
f"""<!doctype html><html><head><meta charset='utf-8'><title>本地支付</title></head>
<body style='font-family:system-ui;padding:40px;text-align:center'>
<h1>NomadCNA 本地开发支付</h1>
<p>订单 {order_id} 已自动标记为支付成功。</p>
<p>3 秒后返回站点完成会员开通。</p>
<script>
localStorage.setItem('join_pay_order', '{cookie_value}');
setTimeout(function(){{ location.href = {redirect_url!r}; }}, 3000);
</script>
<a href='{redirect_url}'>立即返回</a></body></html>"""
)
html_response.set_cookie("join_pay_order", cookie_value, path="/", max_age=900)
return html_response
return {"ok": True, "order_id": order_id, "paid": settings.dev_auto_pay}
@app.get("/api/pay/status")
async def pay_status(order_id: str) -> dict[str, Any]:
order = await pb.first_record("orders", filter=f'orderId={pb_quote(order_id)}')
return {"ok": True, "paid": bool(order and order.get("status") == "paid"), "order": order}
@app.get("/api/cities")
async def list_cities() -> dict[str, Any]:
data = await pb.list_records("cities", per_page=100, sort="idNumber")
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
@app.get("/api/cities/{slug}")
async def city_detail(slug: str) -> dict[str, Any]:
city = await pb.first_record("cities", filter=f'slug={pb_quote(slug)}')
if not city:
raise HTTPException(status_code=404, detail="城市不存在")
detail = await pb.first_record("city_details", filter=f'citySlug={pb_quote(slug)}')
content = await pb.list_records(
"content_items",
filter=f'citySlugs ?~ {pb_quote(slug)}',
per_page=20,
sort="sortOrder",
)
meetups = await pb.list_records("meetups", filter=f'city={pb_quote(city["name"])}', per_page=20)
discussions = await pb.list_records("discussions", filter=f'city={pb_quote(city["name"])}', per_page=20)
return {
"ok": True,
"city": public_record(city),
"detail": public_record(detail) if detail else None,
"content": [public_record(x) for x in content.get("items", [])],
"meetups": [public_record(x) for x in meetups.get("items", [])],
"discussions": [public_record(x) for x in discussions.get("items", [])],
}
@app.get("/api/content/home")
async def home_content() -> dict[str, Any]:
data = await pb.list_records("content_items", filter='status="published"', sort="sortOrder", per_page=20)
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
@app.get("/api/content/{slug}")
async def content_detail(slug: str) -> dict[str, Any]:
item = await pb.first_record("content_items", filter=f'slug={pb_quote(slug)}')
if not item:
raise HTTPException(status_code=404, detail="内容不存在")
cities: list[dict[str, Any]] = []
for city_slug in item.get("citySlugs") or []:
city = await pb.first_record("cities", filter=f'slug={pb_quote(city_slug)}')
if city:
cities.append(public_record(city))
return {"ok": True, "item": public_record(item), "cities": cities}
def city_recommendation_score(city: dict[str, Any], payload: RecommendationPayload) -> tuple[float, list[str]]:
score = float(city.get("overallScore", 0)) * 20
reasons: list[str] = []
cost = int(city.get("costPerMonth") or 999999)
speed = int(city.get("internetSpeed") or 0)
temp = int(city.get("temperature") or 0)
tags = city.get("tags") or []
if cost <= payload.budget:
score += 22
reasons.append("预算内")
else:
over_ratio = min(1.0, (cost - payload.budget) / max(payload.budget, 1))
score -= 18 * over_ratio
if speed >= payload.internet:
score += 18
reasons.append("网络达标")
else:
score -= min(18, payload.internet - speed)
if payload.climate == "warm" and temp >= 20:
score += 8
reasons.append("偏温暖")
elif payload.climate == "cool" and temp <= 20:
score += 8
reasons.append("偏凉爽")
elif payload.climate == "mild" and 14 <= temp <= 24:
score += 10
reasons.append("气候温和")
matched_tags = [tag for tag in payload.tags if tag in tags]
if matched_tags:
score += len(matched_tags) * 5
reasons.append("标签匹配:" + "".join(matched_tags[:3]))
if city.get("nomadsNow", 0) >= 300:
score += 6
reasons.append("社区活跃")
return round(score, 1), reasons[:4]
@app.post("/api/recommendations/next-stop")
async def next_stop_recommendation(payload: RecommendationPayload, request: Request) -> dict[str, Any]:
cities = (await pb.list_records("cities", per_page=100, sort="idNumber")).get("items", [])
scored = []
for city in cities:
score, reasons = city_recommendation_score(city, payload)
scored.append({**public_record(city), "matchScore": score, "matchReasons": reasons})
scored.sort(key=lambda item: item["matchScore"], reverse=True)
picks = scored[: max(1, min(payload.limit, 30))]
_, user = await current_user(request)
try:
await pb.create_record(
"recommendation_logs",
{
"userId": user["id"] if user else "anonymous",
"budget": payload.budget,
"internet": payload.internet,
"climate": payload.climate,
"tags": payload.tags,
"resultSlugs": [city.get("slug") for city in picks],
},
)
except PocketBaseError:
pass
return {"ok": True, "items": picks}
@app.get("/api/routes")
async def list_routes() -> dict[str, Any]:
routes = (await pb.list_records("routes", per_page=100)).get("items", [])
cities = (await pb.list_records("cities", per_page=100, sort="idNumber")).get("items", [])
city_by_slug = {city.get("slug"): public_record(city) for city in cities}
items = []
for route in routes:
record = public_record(route)
record["cities"] = [city_by_slug[slug] for slug in route.get("citySlugs", []) if slug in city_by_slug]
items.append(record)
return {"ok": True, "items": items}
@app.get("/api/services")
async def list_services() -> dict[str, Any]:
data = await pb.list_records("services", filter='status="published"', per_page=100)
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
@app.post("/api/services/leads")
async def create_service_lead(payload: ServiceLeadPayload, request: Request) -> dict[str, Any]:
if not payload.serviceSlug.strip():
raise HTTPException(status_code=400, detail="服务不能为空")
service = await pb.first_record("services", filter=f'slug={pb_quote(payload.serviceSlug)}')
if not service:
raise HTTPException(status_code=404, detail="服务不存在")
_, user = await current_user(request)
record = await pb.create_record(
"service_leads",
{
"serviceSlug": payload.serviceSlug,
"userId": user["id"] if user else "",
"email": payload.email or (user.get("email", "") if user else ""),
"note": payload.note,
"status": "new",
},
)
return {"ok": True, "record": public_record(record), "service": public_record(service)}
@app.get("/api/downloads/{filename}")
async def download_asset(filename: str) -> FastAPIResponse:
if filename == "nomad-china-playbook.pdf":
body = (
"%PDF-1.4\n"
"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"
"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"
"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Contents 4 0 R >> endobj\n"
"4 0 obj << /Length 68 >> stream\n"
"BT /F1 18 Tf 24 88 Td (NomadCNA China Digital Nomad Playbook) Tj ET\n"
"endstream endobj\n"
"xref\n0 5\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n0000000204 00000 n \n"
"trailer << /Root 1 0 R /Size 5 >>\nstartxref\n322\n%%EOF\n"
).encode("latin-1")
return FastAPIResponse(
body,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
if filename == "nomadcna-app-demo.zip":
body = b"NomadCNA App demo package. Replace with real installer artifact later.\n"
return FastAPIResponse(
body,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
raise HTTPException(status_code=404, detail="下载文件不存在")
@app.get("/api/meetups")
async def list_meetups() -> dict[str, Any]:
data = await pb.list_records("meetups", sort="date", per_page=100)
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
@app.post("/api/meetups")
async def create_meetup(payload: dict[str, Any]) -> dict[str, Any]:
if payload.get("date") and "T" not in str(payload["date"]):
payload["date"] = f"{str(payload['date'])[:10]} 00:00:00.000Z"
payload.setdefault("status", "pending")
payload.setdefault("rsvpCount", 0)
payload.setdefault("attendees", [])
record = await pb.create_record("meetups", payload)
return {"ok": True, "record": record}
@app.get("/api/discussions")
async def list_discussions() -> dict[str, Any]:
data = await pb.list_records("discussions", per_page=100)
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
@app.post("/api/discussions")
async def create_discussion(payload: DiscussionPayload) -> dict[str, Any]:
if not payload.title.strip():
raise HTTPException(status_code=400, detail="标题不能为空")
record = await pb.create_record(
"discussions",
{
**payload.model_dump(),
"replies": 0,
"views": 0,
},
)
return {"ok": True, "record": public_record(record)}
@app.get("/api/gigs")
async def list_gigs() -> dict[str, Any]:
data = await pb.list_records("gigs", per_page=100)
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
@app.post("/api/gigs")
async def create_gig(payload: GigPayload, request: Request) -> dict[str, Any]:
if not payload.title.strip():
raise HTTPException(status_code=400, detail="任务标题不能为空")
_, user = await current_user(request)
record = await pb.create_record(
"gigs",
{
**payload.model_dump(),
"status": "open",
},
)
return {"ok": True, "record": public_record(record), "user": public_record(user) if user else None}
@app.post("/api/gigs/{gig_id}/apply")
async def apply_gig(gig_id: str, request: Request) -> dict[str, Any]:
gig = await pb.first_record("gigs", filter=f'id={pb_quote(gig_id)}')
if not gig:
raise HTTPException(status_code=404, detail="任务不存在")
_, user = await current_user(request)
applicant = user.get("email") if user else "anonymous"
application = await pb.create_record(
"gig_applications",
{
"gigId": gig_id,
"userId": user["id"] if user else "",
"applicant": applicant,
"message": "用户从赏金墙接单",
"status": "submitted",
},
)
updated = await pb.update_record("gigs", gig_id, {"status": "in_progress"})
return {
"ok": True,
"message": "已接单",
"applicant": applicant,
"record": public_record(updated),
"application": public_record(application),
}
@app.get("/api/profiles")
async def list_profiles() -> dict[str, Any]:
data = await pb.list_records("profiles", per_page=100)
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
@app.post("/api/matches/swipes")
async def save_swipe(payload: SwipePayload, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
user_id = user["id"] if user else "anonymous"
record = await pb.create_record("swipes", {"userId": user_id, "profileId": payload.profileId, "action": payload.action})
return {"ok": True, "record": record}
@app.post("/api/feedback")
async def feedback(payload: FeedbackPayload) -> dict[str, Any]:
record = await pb.create_record("feedback", {**payload.model_dump(), "status": "new"})
return {"ok": True, "record": record}
@app.get("/api/stats/member-map")
async def member_map() -> dict[str, Any]:
data = await pb.list_records("cities", per_page=100, sort="idNumber")
items = [
{
"id": city.get("idNumber"),
"name": city.get("name"),
"nameEn": city.get("nameEn"),
"emoji": city.get("icon"),
"lat": city.get("lat"),
"lng": city.get("lng"),
"nomadsNow": city.get("nomadsNow"),
}
for city in data.get("items", [])
]
return {"ok": True, "items": items}
@app.get("/api/stats/overview")
async def stats_overview() -> dict[str, Any]:
cities_data = await pb.list_records("cities", per_page=100, sort="idNumber")
meetups_data = await pb.list_records("meetups", per_page=1)
discussions_data = await pb.list_records("discussions", per_page=1)
profiles_data = await pb.list_records("profiles", per_page=1)
gigs_data = await pb.list_records("gigs", per_page=1)
cities = cities_data.get("items", [])
total_nomads = sum(int(city.get("nomadsNow") or 0) for city in cities)
min_cost = min((int(city.get("costPerMonth") or 0) for city in cities), default=0)
avg_cost = round(sum(int(city.get("costPerMonth") or 0) for city in cities) / max(len(cities), 1))
avg_speed = round(sum(int(city.get("internetSpeed") or 0) for city in cities) / max(len(cities), 1))
return {
"ok": True,
"stats": {
"cities": len(cities),
"nomads": total_nomads,
"meetups": meetups_data.get("totalItems", 0),
"discussions": discussions_data.get("totalItems", 0),
"profiles": profiles_data.get("totalItems", 0),
"gigs": gigs_data.get("totalItems", 0),
"minCost": min_cost,
"avgCost": avg_cost,
"avgInternet": avg_speed,
},
}
@app.get("/api/stats/city-ranking")
async def city_ranking(metric: str = "overallScore", limit: int = 12) -> dict[str, Any]:
allowed = {
"overallScore": ("overallScore", True),
"cost": ("costPerMonth", False),
"internet": ("internetSpeed", True),
"community": ("nomadsNow", True),
"temperature": ("temperature", True),
}
field, desc = allowed.get(metric, allowed["overallScore"])
cities = (await pb.list_records("cities", per_page=100, sort="idNumber")).get("items", [])
cities.sort(key=lambda city: float(city.get(field) or 0), reverse=desc)
size = max(1, min(limit, 50))
return {"ok": True, "items": [public_record(city) for city in cities[:size]], "metric": metric}
@app.get("/api/geo/china")
async def china_geo() -> dict[str, Any]:
return {"type": "FeatureCollection", "features": []}
@app.post("/api/ai/assistant")
async def ai_assistant(payload: dict[str, Any]) -> dict[str, Any]:
question = str(payload.get("question", "")).lower()
cities = (await pb.list_records("cities", per_page=100, sort="-overallScore")).get("items", [])
picks = cities[:3]
if "便宜" in question or "budget" in question or "预算" in question:
picks = sorted(cities, key=lambda c: c.get("costPerMonth", 999999))[:3]
elif "" in question or "internet" in question:
picks = sorted(cities, key=lambda c: c.get("internetSpeed", 0), reverse=True)[:3]
answer = "根据当前城市数据,推荐:" + "".join(
f"{c['name']}(¥{c['costPerMonth']}/月,{c['internetSpeed']}Mbps" for c in picks
)
return {"ok": True, "answer": answer, "cities": [public_record(c) for c in picks]}

158
backend/pb_client.py Normal file
View File

@@ -0,0 +1,158 @@
from __future__ import annotations
from typing import Any
from urllib.parse import urlencode
import httpx
from .settings import get_settings
class PocketBaseError(RuntimeError):
pass
def pb_quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
class PocketBaseClient:
def __init__(self) -> None:
self.settings = get_settings()
self._admin_token: str | None = None
self._client: httpx.AsyncClient | None = None
@property
def base_url(self) -> str:
return self.settings.pb_url
async def request(
self,
method: str,
path: str,
*,
token: str | None = None,
admin: bool = False,
json: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
files: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
headers: dict[str, str] = {}
if admin:
token = await self.admin_token()
if token:
headers["Authorization"] = f"Bearer {token}"
url = f"{self.base_url}{path}"
if self._client is None:
self._client = httpx.AsyncClient(timeout=20.0, trust_env=False)
res = await self._client.request(
method,
url,
headers=headers,
json=json,
data=data,
files=files,
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()
async def admin_token(self) -> str:
if self._admin_token:
return self._admin_token
body = {
"identity": self.settings.pb_admin_email,
"password": self.settings.pb_admin_password,
}
endpoints = [
"/api/collections/_superusers/auth-with-password",
"/api/admins/auth-with-password",
]
last_error: Exception | None = None
for endpoint in endpoints:
try:
data = await self.request("POST", endpoint, json=body)
token = data.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}")
async def list_records(
self,
collection: str,
*,
filter: str | None = None,
sort: str | None = None,
page: int = 1,
per_page: int = 50,
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 await self.request(
"GET",
f"/api/collections/{collection}/records",
admin=admin,
params=params,
)
async def first_record(
self,
collection: str,
*,
filter: str,
admin: bool = True,
) -> dict[str, Any] | None:
data = await self.list_records(collection, filter=filter, per_page=1, admin=admin)
items = data.get("items") or []
return items[0] if items else None
async def create_record(self, collection: str, payload: dict[str, Any]) -> dict[str, Any]:
return await self.request(
"POST",
f"/api/collections/{collection}/records",
admin=True,
json=payload,
)
async def update_record(
self,
collection: str,
record_id: str,
payload: dict[str, Any],
) -> dict[str, Any]:
return await self.request(
"PATCH",
f"/api/collections/{collection}/records/{record_id}",
admin=True,
json=payload,
)
async def upsert_by_field(
self,
collection: str,
field: str,
value: str,
payload: dict[str, Any],
) -> dict[str, Any]:
existing = await self.first_record(collection, filter=f"{field}={pb_quote(value)}")
if existing:
return await self.update_record(collection, existing["id"], payload)
return await self.create_record(collection, payload)
pb = PocketBaseClient()

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi>=0.115
uvicorn[standard]>=0.30
httpx>=0.27
python-multipart>=0.0.9
PyJWT>=2.8
passlib[bcrypt]>=1.7
pydantic>=2.7

373
backend/seed_data.py Normal file
View File

@@ -0,0 +1,373 @@
from __future__ import annotations
CITIES = [
{
"slug": "dali",
"idNumber": 1,
"name": "大理",
"nameEn": "Dali",
"country": "中国",
"countryEmoji": "🇨🇳",
"overallScore": 4.5,
"costPerMonth": 3000,
"internetSpeed": 50,
"safety": "很好",
"liked": "极好",
"temperature": 16,
"humidity": 60,
"nomadsNow": 520,
"description": "苍山洱海,文艺气息浓厚的慢生活之城",
"descriptionEn": "Cangshan and Erhai, a slow-living city with a strong creative vibe.",
"gradientFrom": "#2563eb",
"gradientTo": "#0891b2",
"icon": "🏯",
"tags": ["热门", "便宜", "宜居", "山城", "文化", "友好社区"],
"coverImage": "https://images.unsplash.com/photo-1590856029826-c7a73142bbf1?w=1200",
"reviewsCount": 2842,
"likedCount": 198,
"dislikedCount": 32,
"qualityOfLife": "Good",
"familyScore": "Okay",
"communityScore": "Great",
"nomadPercent": 2,
"funLabel": "Great",
"feelsLikeTemp": 18,
"acPercent": 65,
"lat": 25.7,
"lng": 100.2,
"region": "亚洲",
},
{
"slug": "chengdu",
"idNumber": 2,
"name": "成都",
"nameEn": "Chengdu",
"country": "中国",
"countryEmoji": "🇨🇳",
"overallScore": 4.4,
"costPerMonth": 4500,
"internetSpeed": 80,
"safety": "很好",
"liked": "极好",
"temperature": 17,
"humidity": 75,
"nomadsNow": 680,
"description": "天府之国,美食与慢节奏的完美融合",
"descriptionEn": "A laid-back food capital with strong community energy.",
"gradientFrom": "#059669",
"gradientTo": "#65a30d",
"icon": "🐼",
"tags": ["热门", "美食", "都市", "新一线", "文化", "友好社区"],
"coverImage": "https://images.unsplash.com/photo-1547981609-4b6bfe67ca0b?w=1200",
"reviewsCount": 3654,
"likedCount": 312,
"dislikedCount": 28,
"qualityOfLife": "Great",
"familyScore": "Good",
"communityScore": "Great",
"nomadPercent": 3,
"funLabel": "Great",
"feelsLikeTemp": 20,
"acPercent": 85,
"lat": 30.6,
"lng": 104.1,
"region": "亚洲",
},
{
"slug": "shenzhen",
"idNumber": 3,
"name": "深圳",
"nameEn": "Shenzhen",
"country": "中国",
"countryEmoji": "🇨🇳",
"overallScore": 4.1,
"costPerMonth": 8000,
"internetSpeed": 120,
"safety": "极好",
"liked": "很好",
"temperature": 23,
"humidity": 70,
"nomadsNow": 850,
"description": "科技创新之城,创业者的乐园",
"descriptionEn": "A fast-moving tech hub for builders and startup people.",
"gradientFrom": "#1d4ed8",
"gradientTo": "#0284c7",
"icon": "🌃",
"tags": ["热门", "高速网络", "都市", "一线城市", "安全"],
"coverImage": "https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=1200",
"reviewsCount": 4242,
"likedCount": 355,
"dislikedCount": 24,
"qualityOfLife": "Good",
"familyScore": "Good",
"communityScore": "Great",
"nomadPercent": 4,
"funLabel": "Good",
"feelsLikeTemp": 28,
"acPercent": 95,
"lat": 22.5,
"lng": 114.1,
"region": "亚洲",
},
{"slug": "shanghai", "idNumber": 4, "name": "上海", "nameEn": "Shanghai", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 4.0, "costPerMonth": 10000, "internetSpeed": 110, "safety": "极好", "liked": "", "temperature": 17, "humidity": 65, "nomadsNow": 1200, "description": "国际大都市,东西方文化交汇的中心", "descriptionEn": "An international city for global teams and premium urban life.", "gradientFrom": "#b45309", "gradientTo": "#78716c", "icon": "🏙️", "tags": ["热门", "高速网络", "都市", "一线城市", "文化"], "lat": 31.2, "lng": 121.5, "region": "亚洲"},
{"slug": "hangzhou", "idNumber": 5, "name": "杭州", "nameEn": "Hangzhou", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 4.3, "costPerMonth": 6000, "internetSpeed": 100, "safety": "极好", "liked": "很好", "temperature": 17, "humidity": 70, "nomadsNow": 560, "description": "西湖之畔,互联网重镇与诗画江南", "descriptionEn": "West Lake, internet companies, and a polished lifestyle.", "gradientFrom": "#047857", "gradientTo": "#0d9488", "icon": "", "tags": ["热门", "高速网络", "都市", "新一线", "宜居", "文化"], "lat": 30.2, "lng": 120.2, "region": "亚洲"},
{"slug": "xiamen", "idNumber": 6, "name": "厦门", "nameEn": "Xiamen", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 4.1, "costPerMonth": 5500, "internetSpeed": 70, "safety": "很好", "liked": "很好", "temperature": 21, "humidity": 75, "nomadsNow": 320, "description": "海滨花园城市,鼓浪屿的浪漫与闽南风情", "descriptionEn": "A warm coastal city with island life and Fujian culture.", "gradientFrom": "#ea580c", "gradientTo": "#0284c7", "icon": "🌊", "tags": ["海滨", "温暖", "美食", "宜居", "文化"], "lat": 24.5, "lng": 118.1, "region": "亚洲"},
{"slug": "beijing", "idNumber": 7, "name": "北京", "nameEn": "Beijing", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 3.8, "costPerMonth": 9000, "internetSpeed": 100, "safety": "极好", "liked": "", "temperature": 14, "humidity": 50, "nomadsNow": 780, "description": "千年古都,政治文化中心的深厚底蕴", "descriptionEn": "A historic capital with deep culture and broad opportunities.", "gradientFrom": "#dc2626", "gradientTo": "#b91c1c", "icon": "🏛️", "tags": ["都市", "一线城市", "文化", "高速网络", "历史名城"], "lat": 39.9, "lng": 116.4, "region": "亚洲"},
{"slug": "guangzhou", "idNumber": 8, "name": "广州", "nameEn": "Guangzhou", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 3.9, "costPerMonth": 6500, "internetSpeed": 90, "safety": "很好", "liked": "", "temperature": 23, "humidity": 80, "nomadsNow": 480, "description": "食在广州,千年商都的烟火气", "descriptionEn": "A commercial southern city with excellent food and warm winters.", "gradientFrom": "#f97316", "gradientTo": "#ef4444", "icon": "🌺", "tags": ["热门", "美食", "都市", "一线城市", "温暖"], "lat": 23.1, "lng": 113.3, "region": "亚洲"},
{"slug": "chongqing", "idNumber": 9, "name": "重庆", "nameEn": "Chongqing", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 3.7, "costPerMonth": 4000, "internetSpeed": 75, "safety": "很好", "liked": "", "temperature": 18, "humidity": 80, "nomadsNow": 350, "description": "8D魔幻山城火锅与夜景的不夜城", "descriptionEn": "A dramatic mountain city with food, night views, and low cost.", "gradientFrom": "#ef4444", "gradientTo": "#c2410c", "icon": "🌶️", "tags": ["便宜", "美食", "山城", "都市", "文化"], "lat": 29.6, "lng": 106.6, "region": "亚洲"},
{"slug": "kunming", "idNumber": 10, "name": "昆明", "nameEn": "Kunming", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 4.2, "costPerMonth": 3500, "internetSpeed": 55, "safety": "", "liked": "很好", "temperature": 16, "humidity": 55, "nomadsNow": 380, "description": "四季如春的春城,通往东南亚的门户", "descriptionEn": "Spring-like weather year-round and a gateway to Southeast Asia.", "gradientFrom": "#d946ef", "gradientTo": "#059669", "icon": "🌸", "tags": ["便宜", "温暖", "宜居", "友好社区", "户外运动"], "lat": 25.0, "lng": 102.7, "region": "亚洲"},
{"slug": "sanya", "idNumber": 11, "name": "三亚", "nameEn": "Sanya", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 3.5, "costPerMonth": 5000, "internetSpeed": 60, "safety": "", "liked": "", "temperature": 26, "humidity": 85, "nomadsNow": 250, "description": "中国的热带天堂,椰风海韵的度假胜地", "descriptionEn": "A tropical beach city for winter sun and slower workdays.", "gradientFrom": "#0ea5e9", "gradientTo": "#06b6d4", "icon": "🏖️", "tags": ["海滨", "温暖", "户外运动"], "lat": 18.3, "lng": 109.5, "region": "亚洲"},
{"slug": "lijiang", "idNumber": 12, "name": "丽江", "nameEn": "Lijiang", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 4.0, "costPerMonth": 3200, "internetSpeed": 45, "safety": "", "liked": "", "temperature": 14, "humidity": 50, "nomadsNow": 280, "description": "玉龙雪山下的纳西古城,灵魂栖息地", "descriptionEn": "An old town below Jade Dragon Snow Mountain for reflective stays.", "gradientFrom": "#6366f1", "gradientTo": "#7c3aed", "icon": "🏔️", "tags": ["便宜", "文化", "山城", "宜居", "历史名城"], "lat": 26.9, "lng": 100.2, "region": "亚洲"},
{"slug": "xian", "idNumber": 13, "name": "西安", "nameEn": "Xi'an", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 3.8, "costPerMonth": 4200, "internetSpeed": 70, "safety": "很好", "liked": "", "temperature": 15, "humidity": 55, "nomadsNow": 230, "description": "十三朝古都,历史与美食交织的城市", "descriptionEn": "Ancient history, strong food culture, and reasonable costs.", "gradientFrom": "#78716c", "gradientTo": "#a16207", "icon": "🗿", "tags": ["便宜", "美食", "文化", "历史名城"], "lat": 34.3, "lng": 108.9, "region": "亚洲"},
{"slug": "nanjing", "idNumber": 14, "name": "南京", "nameEn": "Nanjing", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 3.9, "costPerMonth": 5500, "internetSpeed": 85, "safety": "很好", "liked": "", "temperature": 16, "humidity": 70, "nomadsNow": 400, "description": "六朝古都,紫金山下的文化名城", "descriptionEn": "A cultural city with universities, history, and steady infrastructure.", "gradientFrom": "#7c3aed", "gradientTo": "#059669", "icon": "🍃", "tags": ["文化", "都市", "新一线", "历史名城"], "lat": 32.1, "lng": 118.8, "region": "亚洲"},
{"slug": "changsha", "idNumber": 15, "name": "长沙", "nameEn": "Changsha", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 3.6, "costPerMonth": 4500, "internetSpeed": 75, "safety": "", "liked": "", "temperature": 17, "humidity": 75, "nomadsNow": 290, "description": "娱乐之都,夜生活与美食的不眠城", "descriptionEn": "A media and food city with active nightlife and young energy.", "gradientFrom": "#f59e0b", "gradientTo": "#16a34a", "icon": "🎪", "tags": ["美食", "都市", "新一线", "友好社区"], "lat": 28.2, "lng": 112.9, "region": "亚洲"},
{"slug": "qingdao", "idNumber": 16, "name": "青岛", "nameEn": "Qingdao", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 3.7, "costPerMonth": 4800, "internetSpeed": 65, "safety": "很好", "liked": "", "temperature": 13, "humidity": 65, "nomadsNow": 220, "description": "红瓦绿树碧海蓝天,啤酒飘香的海滨城市", "descriptionEn": "A northern coastal city with sea air, beer, and livable districts.", "gradientFrom": "#2563eb", "gradientTo": "#ca8a04", "icon": "🍺", "tags": ["海滨", "美食", "宜居", "户外运动"], "lat": 36.1, "lng": 120.4, "region": "亚洲"},
{"slug": "suzhou", "idNumber": 17, "name": "苏州", "nameEn": "Suzhou", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 4.0, "costPerMonth": 5000, "internetSpeed": 80, "safety": "极好", "liked": "很好", "temperature": 17, "humidity": 70, "nomadsNow": 260, "description": "上有天堂下有苏杭,古典园林与现代新城", "descriptionEn": "Classical gardens, modern industry, and a calm Jiangnan lifestyle.", "gradientFrom": "#0d9488", "gradientTo": "#64748b", "icon": "🏡", "tags": ["文化", "宜居", "安全", "历史名城"], "lat": 31.3, "lng": 120.6, "region": "亚洲"},
{"slug": "zhuhai", "idNumber": 18, "name": "珠海", "nameEn": "Zhuhai", "country": "中国", "countryEmoji": "🇨🇳", "overallScore": 3.9, "costPerMonth": 5200, "internetSpeed": 70, "safety": "很好", "liked": "", "temperature": 23, "humidity": 75, "nomadsNow": 180, "description": "浪漫之城,毗邻澳门的海滨花园", "descriptionEn": "A relaxed coastal city next to Macau with clean streets and sea views.", "gradientFrom": "#0891b2", "gradientTo": "#6366f1", "icon": "🎡", "tags": ["海滨", "温暖", "宜居", "安全"], "lat": 22.3, "lng": 113.6, "region": "亚洲"},
]
MEETUPS = [
{"city": "大理", "emoji": "🏯", "date": "2026-07-15", "time": "14:00", "venue": "大理古城人民路咖啡馆", "address": "大理市大理古城人民路中段", "description": "苍山脚下,洱海边,与大理的慢生活游民们一起喝咖啡、晒太阳、聊人生。", "rsvpCount": 12, "gradientFrom": "#3b82f6", "gradientTo": "#4f46e5", "attendees": [{"name": "林晓", "photo": "https://i.pravatar.cc/150?img=1"}, {"name": "张明", "photo": "https://i.pravatar.cc/150?img=2"}], "isUpcoming": True},
{"city": "成都", "emoji": "🐼", "date": "2026-07-18", "time": "19:00", "venue": "方所书店", "address": "成都市锦江区中纱帽街8号远洋太古里", "description": "天府之国游民聚会,在文艺书店里品茶聊天,探讨成都的慢生活与远程工作可能性。", "rsvpCount": 9, "gradientFrom": "#22c55e", "gradientTo": "#10b981", "attendees": [{"name": "杨帆", "photo": "https://i.pravatar.cc/150?img=6"}], "isUpcoming": True},
{"city": "深圳", "emoji": "🌃", "date": "2026-07-20", "time": "19:30", "venue": "WeWork 卓越前海壹号", "address": "深圳市南山区前海深港合作区卓越前海壹号", "description": "深圳数字游民线下见面会,探讨创业、远程工作与湾区发展机遇。", "rsvpCount": 15, "gradientFrom": "#ff4d4f", "gradientTo": "#ff7a45", "attendees": [{"name": "李娜", "photo": "https://i.pravatar.cc/150?img=10"}], "isUpcoming": True},
{"city": "杭州", "emoji": "", "date": "2026-05-25", "time": "18:00", "venue": "梦想小镇·良仓咖啡", "address": "杭州市余杭区梦想小镇互联网村", "description": "西湖畔数字游民聚会,交流产品与运营经验。", "rsvpCount": 11, "gradientFrom": "#06b6d4", "gradientTo": "#0d9488", "attendees": [], "isUpcoming": False},
]
DISCUSSIONS = [
{"title": "大理最佳共享办公空间推荐 Top 10", "author": "林晓", "city": "大理", "tags": ["共享办公", "大理"], "replies": 24, "views": 2340, "excerpt": "整理了古城和洱海周边几家适合长期办公的地方。"},
{"title": "2026 年数字游民友好城市排名", "author": "NomadCNA", "city": "上海", "tags": ["城市", "报告"], "replies": 18, "views": 1890, "excerpt": "从成本、网络、社区和气候四个维度重新排名。"},
{"title": "远程工作者的税务规划指南", "author": "李老师", "city": "深圳", "tags": ["税务", "远程工作"], "replies": 12, "views": 1560, "excerpt": "基础概念、常见误区和需要咨询专业人士的节点。"},
]
GIGS = [
{"title": "整理大理共享办公空间清单", "category": "内容", "budget": 500, "location": "远程", "description": "收集 20 个空间的价格、地址、网络和照片。", "status": "open"},
{"title": "设计数字游民城市评分图标", "category": "设计", "budget": 800, "location": "远程", "description": "为成本、网速、安全、社区氛围设计一组图标。", "status": "open"},
{"title": "深圳线下聚会摄影", "category": "活动", "budget": 1200, "location": "深圳", "description": "拍摄 2 小时活动现场并交付精选照片。", "status": "open"},
]
PROFILES = [
{"name": "林晓雨", "age": 28, "location": "大理", "tags": ["本科学历", "远程工作者", "数字游民", "咖啡爱好者"], "bio": "数字游民三年,目前在云南大理远程做产品设计。喜欢爬山、摄影,周末常去洱海边发呆。", "photo": "https://i.pravatar.cc/150?img=1"},
{"name": "陈浩然", "age": 32, "location": "成都", "tags": ["硕士学历", "独立开发者", "自由职业者", "素食主义"], "bio": "在成都住了两年,做独立开发。喜欢冲浪和冥想,寻找志同道合的旅伴一起探索世界。", "photo": "https://i.pravatar.cc/150?img=3"},
{"name": "王思琪", "age": 26, "location": "深圳", "tags": ["本科学历", "内容创作者", "数字游民", "环保主义者"], "bio": "自由撰稿人,在深圳写写画画。热爱瑜伽和冥想,相信慢生活才是真正的奢侈。", "photo": "https://i.pravatar.cc/150?img=5"},
]
CONTENT_ITEMS = [
{
"slug": "nomad-china-playbook",
"type": "ebook",
"title": "《中国数字游民落地手册》",
"titleEn": "China Digital Nomad Playbook",
"subtitle": "从选城、预算、租房到社群融入的一本实操指南",
"subtitleEn": "A practical guide for choosing cities, budgeting, housing, and community.",
"description": "这本电子书把城市决策拆成 7 个步骤:预算、气候、网络、工作空间、社群、住宿和长期身份。每一章都关联本平台真实城市数据,后续可根据你的收藏城市自动生成个性化版本。",
"descriptionEn": "A seven-step playbook connected to live city data: budget, climate, internet, workspace, community, housing, and long-stay planning.",
"coverImage": "https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=1200",
"mediaUrl": "http://127.0.0.1:8000/api/downloads/nomad-china-playbook.pdf",
"targetUrl": "/ebooks/nomad-china-playbook",
"ctaLabel": "查看电子书",
"ctaLabelEn": "View ebook",
"citySlugs": ["dali", "chengdu", "shenzhen", "hangzhou"],
"relatedProfiles": ["林晓雨", "陈浩然"],
"chapters": [
{"title": "如何判断一座城市是否适合远程工作", "minutes": 8},
{"title": "月预算拆解:房租、办公、交通、社交", "minutes": 10},
{"title": "30 天落地清单:从住宿到本地社群", "minutes": 12},
],
"durationMinutes": 42,
"sortOrder": 1,
"status": "published",
},
{
"slug": "lin-dali-documentary",
"type": "video",
"title": "林晓雨:在大理远程做产品设计的一天",
"titleEn": "Lin Xiaoyu: A remote product designer day in Dali",
"subtitle": "个人访谈 / 现实纪录片 · 大理",
"subtitleEn": "Interview documentary · Dali",
"description": "她把工作日切成晨间深度工作、午后咖啡馆协作和傍晚洱海散步。视频关联大理城市详情、共享办公和本地活动。",
"descriptionEn": "A day split into deep work, cafe collaboration, and an Erhai walk.",
"coverImage": "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200",
"mediaUrl": "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
"targetUrl": "/videos/lin-dali-documentary",
"ctaLabel": "观看访谈",
"ctaLabelEn": "Watch",
"citySlugs": ["dali"],
"relatedProfiles": ["林晓雨"],
"chapters": [
{"title": "为什么离开一线城市", "time": "00:00"},
{"title": "远程团队如何协作", "time": "03:20"},
{"title": "大理生活成本复盘", "time": "07:45"},
],
"durationMinutes": 12,
"sortOrder": 2,
"status": "published",
},
{
"slug": "nomadcna-app",
"type": "app",
"title": "NomadCNA App",
"titleEn": "NomadCNA App",
"subtitle": "记录旅居路线、发现同城活动、保存城市清单",
"subtitleEn": "Track routes, discover local meetups, and save city lists.",
"description": "App 落地页提供 Windows/Android/iOS 下载入口。本地 MVP 先提供模拟下载和功能预览,后续可接真实安装包、版本更新和用户设备同步。",
"descriptionEn": "The app landing page provides downloads and feature previews.",
"coverImage": "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=1200",
"mediaUrl": "http://127.0.0.1:8000/api/downloads/nomadcna-app-demo.zip",
"targetUrl": "/app",
"ctaLabel": "下载 App",
"ctaLabelEn": "Download app",
"citySlugs": ["dali", "chengdu", "shenzhen", "hangzhou", "xiamen"],
"relatedProfiles": ["林晓雨", "陈浩然", "王思琪"],
"chapters": [
{"title": "旅居路线追踪", "icon": "🧭"},
{"title": "同城活动提醒", "icon": "🍹"},
{"title": "城市收藏和对比", "icon": ""},
],
"durationMinutes": 0,
"sortOrder": 3,
"status": "published",
},
{
"slug": "digital-nomad-guide",
"type": "guide",
"title": "数字游民指南",
"titleEn": "Digital Nomad Guide",
"subtitle": "完整方法论、工具、案例和长期更新",
"subtitleEn": "Methods, tools, cases, and long-term updates.",
"description": "跳转到 digital.hackrobot.cn 的数字游民专题站。当前站内城市、访谈、App 与专题指南互相导流。",
"descriptionEn": "External guide site connected with city pages, interviews, and the app.",
"coverImage": "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200",
"mediaUrl": "",
"targetUrl": "https://digital.hackrobot.cn/zh",
"ctaLabel": "打开指南",
"ctaLabelEn": "Open guide",
"citySlugs": ["dali", "chengdu", "shenzhen"],
"relatedProfiles": [],
"chapters": [
{"title": "远程工作基础设施", "minutes": 6},
{"title": "如何设计你的第一条旅居路线", "minutes": 9},
{"title": "如何避免孤独和信息差", "minutes": 7},
],
"durationMinutes": 22,
"sortOrder": 4,
"status": "published",
},
]
def _city_detail(city: dict, index: int) -> dict:
slug = city["slug"]
name = city["name"]
cost = int(city["costPerMonth"])
cowork = max(600, round(cost * 0.18))
rent = max(1200, round(cost * 0.42))
food = max(900, round(cost * 0.26))
transport = max(200, round(cost * 0.08))
leisure = max(300, cost - rent - food - cowork - transport)
return {
"citySlug": slug,
"guide": {
"summary": f"{name}适合想把远程工作和生活质量同时拉高的人。建议先用 2-4 周短住验证网络、社群和生活节奏,再决定是否长期停留。",
"bestFor": city.get("tags", [])[:4],
"workSetup": f"建议选择靠近核心生活区的住处,搭配 1 个固定共享办公点和 2 个备用咖啡馆。当前网速参考值为 {city['internetSpeed']} Mbps。",
"arrivalChecklist": ["确认 4G/5G 覆盖", "预约第一周住宿", "加入本地社群", "收藏 2 个办公点", "报名一场线下活动"],
"linkedContent": ["nomad-china-playbook", "digital-nomad-guide"],
},
"pros": {
"pros": [
f"月均生活成本约 ¥{cost:,},适合控制预算。",
f"当前估算有 {city['nomadsNow']} 位游民活跃,容易找到同频伙伴。",
f"城市标签覆盖:{''.join(city.get('tags', [])[:5])}",
],
"cons": [
"热门区域旺季住宿价格波动较大。",
"长期停留前仍需实测具体住处网络。",
"社交圈集中,需要主动参加活动融入。",
],
},
"reviews": {
"rating": city["overallScore"],
"items": [
{"author": "林晓雨", "role": "产品设计师", "text": f"{name}最适合有稳定远程收入的人,白天专注工作,晚上参加小规模聚会。", "score": 5},
{"author": "陈浩然", "role": "独立开发者", "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": city["nomadsNow"],
"personas": [
{"name": "远程全职", "percent": 36},
{"name": "自由职业", "percent": 28},
{"name": "独立开发者", "percent": 18},
{"name": "内容创作者", "percent": 18},
],
"featuredProfiles": ["林晓雨", "陈浩然", "王思琪"] if index < 6 else ["陈浩然", "王思琪"],
},
"chat": {
"channels": [
{"name": f"{name}落地互助群", "members": min(999, city["nomadsNow"]), "status": "open"},
{"name": f"{name}共享办公情报", "members": round(city["nomadsNow"] * 0.42), "status": "open"},
{"name": "周末活动约局", "members": round(city["nomadsNow"] * 0.28), "status": "members"},
],
"latestTopics": ["本周线下咖啡局", "哪里适合视频会议", "短租避坑清单"],
},
"photos": {
"items": [
city.get("coverImage") or "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200",
"https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=1200",
"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200",
]
},
"weather": {
"temperature": city["temperature"],
"humidity": city["humidity"],
"bestMonths": ["3月", "4月", "10月", "11月"],
"note": f"当前气候估算适合中长期试住,体感温度需结合季节和住宿条件判断。",
},
"trends": {
"growth": [
{"month": "1月", "value": max(50, city["nomadsNow"] - 160)},
{"month": "2月", "value": max(70, city["nomadsNow"] - 120)},
{"month": "3月", "value": max(90, city["nomadsNow"] - 80)},
{"month": "4月", "value": max(120, city["nomadsNow"] - 40)},
{"month": "5月", "value": city["nomadsNow"]},
],
"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": ["nomad-china-playbook", "lin-dali-documentary" if slug == "dali" else "digital-nomad-guide", "nomadcna-app"],
}
CITY_DETAILS = [_city_detail(city, idx) for idx, city in enumerate(CITIES)]

30
backend/settings.py Normal file
View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import os
from functools import lru_cache
from pathlib import Path
def _split_csv(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
class Settings:
def __init__(self) -> None:
self.pb_url = os.getenv("PB_URL", "http://127.0.0.1:8090").rstrip("/")
self.pb_admin_email = os.getenv("PB_ADMIN_EMAIL", "admin@nomadcna.local")
self.pb_admin_password = os.getenv("PB_ADMIN_PASSWORD", "NomadCNA123456")
self.cookie_name = os.getenv("API_COOKIE_NAME", "pb_session")
self.frontend_origins = _split_csv(
os.getenv(
"FRONTEND_ORIGINS",
"http://localhost:3001,http://127.0.0.1:3001",
)
)
self.upload_dir = Path(os.getenv("UPLOAD_DIR", "backend/uploads"))
self.dev_auto_pay = os.getenv("DEV_AUTO_PAY", "true").lower() == "true"
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()