s''
This commit is contained in:
7
backend/.env.example
Normal file
7
backend/.env.example
Normal 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
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""NomadCNA local FastAPI backend."""
|
||||
BIN
backend/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/enhanced_seed.cpython-312.pyc
Normal file
BIN
backend/__pycache__/enhanced_seed.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/init_db.cpython-312.pyc
Normal file
BIN
backend/__pycache__/init_db.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/pb_client.cpython-312.pyc
Normal file
BIN
backend/__pycache__/pb_client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/seed_data.cpython-312.pyc
Normal file
BIN
backend/__pycache__/seed_data.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/settings.cpython-312.pyc
Normal file
BIN
backend/__pycache__/settings.cpython-312.pyc
Normal file
Binary file not shown.
180
backend/enhanced_seed.py
Normal file
180
backend/enhanced_seed.py
Normal 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
325
backend/init_db.py
Normal 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
883
backend/main.py
Normal 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
158
backend/pb_client.py
Normal 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
7
backend/requirements.txt
Normal 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
373
backend/seed_data.py
Normal 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
30
backend/settings.py
Normal 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()
|
||||
Reference in New Issue
Block a user