nomadweb/backend/app/schemas.py
eric ff7b721acb feat: NomadCNA community layer, social matching, payments, and ebook
Port meetups, discussions, gigs, dating/chat, VIP pay (ZPay/XorPay), MiroTalk live, digital academy, and ebook reader. Add persistent community_store, git-first deploy docs, and env template for production secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 09:38:13 -05:00

472 lines
8.9 KiB
Python

from pydantic import BaseModel, EmailStr, Field
class Destination(BaseModel):
id: str
slug: str
name: str
country: str
emoji: str
tag: str
description: str
region: str
cost: int
speed: int
temperature: int
rating: float
hue: int = 170
nomads_count: str = "5,000+"
highlights: list[str] = []
map_x: float = 0
map_y: float = 0
class Visa(BaseModel):
id: str
country: str
flag: str
name: str
badge: str
badge_type: str
duration: str
income_req: str
approval_time: str
extra: str
difficulty: int
difficulty_label: str
class FAQ(BaseModel):
id: str
question: str
answer: str
order: int = 0
class Testimonial(BaseModel):
id: str
avatar: str
content: str
author: str
role: str
rating: int = 5
class Tool(BaseModel):
id: str
emoji: str
name: str
description: str
tags: list[str]
category: str
class SubscribeRequest(BaseModel):
email: EmailStr
class SubscribeResponse(BaseModel):
success: bool
message: str
class CostCalculatorRequest(BaseModel):
destination_slug: str
housing: str = "mid" # budget | mid | premium
months: int = Field(default=1, ge=1, le=24)
class CostCalculatorResponse(BaseModel):
destination: str
emoji: str
months: int
breakdown: dict[str, int]
total: int
per_month: int
class CompareRequest(BaseModel):
slugs: list[str] = Field(..., min_length=2, max_length=4)
class StatsResponse(BaseModel):
countries: int
avg_cost: int
satisfaction: int
total_nomads: str
active_today: int
class ChartData(BaseModel):
labels: list[str]
datasets: list[dict]
class BlogPost(BaseModel):
id: str
slug: str
title: str
excerpt: str
emoji: str
author: str
published_at: str
read_time: int
tags: list[str] = []
class BlogPostDetail(BlogPost):
content: str
class AuthRegister(BaseModel):
email: EmailStr
password: str = Field(min_length=6)
name: str = Field(min_length=1)
class AuthLogin(BaseModel):
email: EmailStr
password: str
class UserProfile(BaseModel):
id: str
email: str
name: str
avatar: str = "🧑‍💻"
class AuthResponse(BaseModel):
token: str
user: UserProfile
class FavoriteRequest(BaseModel):
destination_slug: str
class FavoriteResponse(BaseModel):
slugs: list[str]
class ProfileStats(BaseModel):
favorites_count: int
destinations_explored: int
member_since: str
nomad_level: str
class TripItemModel(BaseModel):
slug: str
name: str
country: str
emoji: str
cost: float
months: int = 1
note: str = ""
class PlanMetaModel(BaseModel):
title: str = "我的旅居计划"
startMonth: str = ""
monthlyBudget: float = 0
checklist: dict[str, bool] = Field(default_factory=dict)
class UserPlanPayload(BaseModel):
items: list[TripItemModel] = Field(default_factory=list)
meta: PlanMetaModel = Field(default_factory=PlanMetaModel)
updated_at: int = 0
class SearchResult(BaseModel):
type: str # destination | blog | visa | faq
title: str
subtitle: str
emoji: str
url: str
class Meetup(BaseModel):
id: str
title: str
city: str
destination_slug: str = ""
emoji: str
date: str
time: str
venue: str
description: str
mode: str # online | offline | hybrid
access_level: str = "public"
mirotalkRoom: str = ""
loungeChannel: str = ""
rsvp_count: int = 0
max_attendees: int = 50
organizer: str = ""
tags: list[str] = []
is_upcoming: bool = True
class Discussion(BaseModel):
id: str
title: str
excerpt: str
author: str
author_emoji: str = "🧑‍💻"
category: str
reply_count: int = 0
like_count: int = 0
is_pinned: bool = False
created_at: str
tags: list[str] = []
class DiscussionReply(BaseModel):
id: str
author: str
author_emoji: str = "🧑‍💻"
content: str
created_at: str
like_count: int = 0
class DiscussionDetail(Discussion):
replies: list[DiscussionReply] = []
class NomadRoute(BaseModel):
id: str
slug: str
title: str
duration_days: int
budget: int
description: str
stops: list[str]
emoji: str = "🗺️"
class NextStopRequest(BaseModel):
budget: int = Field(default=8000, ge=2000, le=50000)
internet: int = Field(default=50, ge=10, le=500)
climate: str = "mild" # warm | mild | cool
tags: list[str] = Field(default_factory=list)
priority: str = "balanced" # cost | speed | community | balanced
limit: int = Field(default=12, ge=1, le=24)
class RecommendedDestination(Destination):
match_score: float = 0
match_reasons: list[str] = []
class NextStopResponse(BaseModel):
items: list[RecommendedDestination]
routes: list[NomadRoute] = []
class MeetupRsvpRequest(BaseModel):
meetup_id: str
name: str = Field(default="访客", min_length=1)
# ── Social / matching / chat ──
class MatchProfile(BaseModel):
id: str
userId: str = ""
name: str
location: str = ""
citySlug: str = ""
gender: str = ""
single: str = ""
bio: str = ""
photo: str = "🧑‍💻"
tags: list[str] = []
lookingFor: list[str] = []
intent: str = ""
class SwipeRequest(BaseModel):
profileId: str
action: str = "like" # like | dislike | superlike
intent: str = "friends"
class SwipeResponse(BaseModel):
ok: bool
matched: bool = False
match: dict | None = None
error: str | None = None
class MatchQuota(BaseModel):
vip: bool
limit: int
used: int
remaining: int
class JoinMemberRequest(BaseModel):
city: str = ""
citySlug: str = ""
gender: str = ""
single: str = ""
bio: str = ""
lookingFor: list[str] = Field(default_factory=lambda: ["friends", "explore"])
photo: str = "🧑‍💻"
class ChatPeer(BaseModel):
id: str = ""
userId: str = ""
name: str = ""
photo: str = "🧑‍💻"
location: str = ""
class ConversationItem(BaseModel):
id: str
type: str = "direct"
intent: str = ""
lastMessageAt: str = ""
lastMessagePreview: str = ""
unreadCount: int = 0
peer: MatchProfile | None = None
class ChatMessage(BaseModel):
id: str
conversationId: str = ""
senderId: str
body: str
createdAt: str = ""
mine: bool = False
class SendMessageRequest(BaseModel):
body: str = Field(min_length=1, max_length=2000)
class VipStatus(BaseModel):
vip: bool
expires_at: int = 0
class PayCreateRequest(BaseModel):
pay_type: str = "join" # join | vip | meetup
return_url: str = ""
provider: str = "zpay"
class PayCreateResponse(BaseModel):
order_id: str
amount: int
redirect_url: str
status: str
class PayStatusResponse(BaseModel):
order_id: str
status: str
paid: bool
class MeetupSession(BaseModel):
canJoin: bool
lockReason: str = ""
displayName: str = ""
videoUrl: str = ""
chatUrl: str = ""
loungeChannel: str = ""
mirotalkRoom: str = ""
meetingProvider: str = "mirotalk"
mode: str = ""
class DigitalLesson(BaseModel):
title: str
moduleIndex: int
lessonIndex: int
duration: str = ""
free: bool = False
content: str = ""
class DigitalJob(BaseModel):
id: str
title: str
company: str
location: str
type: str
salary: str
tags: list[str] = []
url: str = ""
class CreateDiscussionRequest(BaseModel):
title: str = Field(min_length=4, max_length=120)
excerpt: str = ""
content: str = ""
category: str = "社区"
tags: list[str] = Field(default_factory=list)
author_emoji: str = "🧑‍💻"
class CreateReplyRequest(BaseModel):
content: str = Field(min_length=1, max_length=4000)
author_emoji: str = "🧑‍💻"
class CreateMeetupRequest(BaseModel):
title: str = Field(min_length=4, max_length=120)
city: str = "线上"
destination_slug: str = ""
emoji: str = "🎉"
date: str = ""
time: str = "19:00"
venue: str = ""
description: str = ""
mode: str = "offline"
access_level: str = "public"
max_attendees: int = 30
tags: list[str] = Field(default_factory=list)
mirotalkRoom: str = ""
loungeChannel: str = ""
class GigItem(BaseModel):
id: str
title: str
description: str
budget: str
deadline: str
tags: list[str] = []
poster: str = ""
status: str = "open"
class GigApplyRequest(BaseModel):
message: str = Field(min_length=4, max_length=2000)
class NotificationItem(BaseModel):
id: str
title: str
body: str = ""
link: str = ""
read: bool = False
created_at: str = ""
class FeedbackRequest(BaseModel):
content: str = Field(min_length=4, max_length=4000)
category: str = "general"
name: str = ""
class StatsOverview(BaseModel):
meetups: int = 0
discussions: int = 0
gigs: int = 0
members_active: int = 0