462 lines
15 KiB
Python
462 lines
15 KiB
Python
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
|
|
|
|
|
|
NOTIFICATION_SEEDS: list[dict[str, Any]] = [
|
|
{
|
|
"title": "欢迎来到游牧中国消息中心",
|
|
"body": "这里会集中显示活动提醒、赏金任务、服务咨询、匹配动态、城市更新和系统公告。你可以筛选、已读、归档,也可以从消息直接跳转到对应功能。",
|
|
"category": "system",
|
|
"priority": "high",
|
|
"targetUserId": "",
|
|
"audience": "all",
|
|
"actionLabel": "查看仪表盘",
|
|
"actionUrl": "/dashboard",
|
|
"entityType": "onboarding",
|
|
"entityId": "message-center",
|
|
"icon": "🔔",
|
|
"status": "published",
|
|
"channels": ["in_app"],
|
|
"metadata": {"source": "seed", "tone": "welcome"},
|
|
},
|
|
{
|
|
"title": "本周同城活动已更新",
|
|
"body": "大理、成都、深圳等城市新增了线下聚会,你可以在活动页查看时间、地点和报名人数。",
|
|
"category": "meetup",
|
|
"priority": "normal",
|
|
"targetUserId": "",
|
|
"audience": "all",
|
|
"actionLabel": "查看活动",
|
|
"actionUrl": "/meetups",
|
|
"entityType": "meetup",
|
|
"entityId": "weekly",
|
|
"icon": "🍹",
|
|
"status": "published",
|
|
"channels": ["in_app"],
|
|
"metadata": {"source": "seed"},
|
|
},
|
|
{
|
|
"title": "赏金墙有新任务可接",
|
|
"body": "城市资料补充、共享办公调研、活动摄影等任务已经开放,接单后会生成站内通知记录。",
|
|
"category": "gig",
|
|
"priority": "normal",
|
|
"targetUserId": "",
|
|
"audience": "all",
|
|
"actionLabel": "去接任务",
|
|
"actionUrl": "/gigs",
|
|
"entityType": "gig",
|
|
"entityId": "open-board",
|
|
"icon": "💰",
|
|
"status": "published",
|
|
"channels": ["in_app"],
|
|
"metadata": {"source": "seed"},
|
|
},
|
|
]
|
|
|
|
|
|
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),
|
|
],
|
|
"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"),
|
|
],
|
|
"site_vip": [
|
|
text("user_id", required=True, max=80),
|
|
text("site_id", required=True, max=80),
|
|
text("order_id", max=120),
|
|
date("expires_at"),
|
|
],
|
|
"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"),
|
|
],
|
|
"payments": [
|
|
text("user_id", max=120),
|
|
text("type", max=60),
|
|
text("order_id", required=True, max=120),
|
|
number("amount", only_int=True),
|
|
number("total_fee", only_int=True),
|
|
text("channel", max=40),
|
|
],
|
|
"media_assets": [
|
|
text("userId", max=80),
|
|
text("url", required=True, max=500),
|
|
text("filename", max=255),
|
|
text("contentType", max=120),
|
|
number("size", only_int=True),
|
|
text("objectKey", max=500),
|
|
],
|
|
"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),
|
|
text("mode", max=40),
|
|
number("maxAttendees", only_int=True),
|
|
text("organizer", max=120),
|
|
text("accessLevel", max=40),
|
|
text("meetingProvider", max=80),
|
|
text("mirotalkRoom", max=160),
|
|
text("meetingUrl", max=500),
|
|
text("replayUrl", max=500),
|
|
json_field("features"),
|
|
],
|
|
"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"),
|
|
],
|
|
"notifications": [
|
|
text("title", required=True, max=255),
|
|
text("body", max=2000),
|
|
text("category", max=80),
|
|
text("priority", max=40),
|
|
text("targetUserId", max=80),
|
|
text("audience", max=40),
|
|
text("actionLabel", max=120),
|
|
text("actionUrl", max=500),
|
|
text("entityType", max=80),
|
|
text("entityId", max=120),
|
|
text("icon", max=16),
|
|
text("status", max=40),
|
|
json_field("channels"),
|
|
json_field("metadata"),
|
|
date("expiresAt"),
|
|
],
|
|
"notification_receipts": [
|
|
text("notificationId", required=True, max=80),
|
|
text("userId", required=True, max=80),
|
|
date("readAt"),
|
|
date("archivedAt"),
|
|
date("dismissedAt"),
|
|
boolean("pinned"),
|
|
],
|
|
"notification_preferences": [
|
|
text("userId", required=True, max=80),
|
|
text("email", max=255),
|
|
json_field("channels"),
|
|
json_field("categories"),
|
|
text("quietHoursStart", max=10),
|
|
text("quietHoursEnd", max=10),
|
|
text("digest", max=40),
|
|
boolean("allowMarketing"),
|
|
],
|
|
"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),
|
|
text("authorName", max=120),
|
|
text("authorEmail", max=255),
|
|
text("submittedBy", max=80),
|
|
text("reviewStatus", max=40),
|
|
text("reviewNote", max=1000),
|
|
date("reviewedAt"),
|
|
json_field("submissionMeta"),
|
|
],
|
|
"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 ensure_collection_fields(name: str, fields: list[dict[str, Any]]) -> None:
|
|
collection = await pb.request("GET", f"/api/collections/{name}", admin=True)
|
|
field_key = "fields" if "fields" in collection else "schema"
|
|
current_fields = collection.get(field_key) or []
|
|
current_names = {field.get("name") for field in current_fields}
|
|
missing_fields = [field for field in fields if field.get("name") not in current_names]
|
|
if not missing_fields:
|
|
return
|
|
await pb.request(
|
|
"PATCH",
|
|
f"/api/collections/{collection.get('id') or name}",
|
|
admin=True,
|
|
json={field_key: [*current_fields, *missing_fields]},
|
|
)
|
|
print(f"collection updated: {name} +{len(missing_fields)} fields")
|
|
|
|
|
|
async def create_collection(name: str, fields: list[dict[str, Any]]) -> None:
|
|
if await collection_exists(name):
|
|
await ensure_collection_fields(name, fields)
|
|
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)
|
|
await upsert_seed("notifications", "title", NOTIFICATION_SEEDS)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|