This commit is contained in:
eric
2026-06-06 07:49:47 -05:00
parent 41493c35ca
commit f5c5819bdf
9 changed files with 1398 additions and 7 deletions

View File

@@ -16,6 +16,58 @@ from .seed_data import (
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}
@@ -205,6 +257,41 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = {
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),
@@ -319,6 +406,7 @@ async def main() -> None:
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__":

View File

@@ -11,7 +11,7 @@ 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 pydantic import BaseModel, Field
from .pb_client import PocketBaseError, pb, pb_quote
from .settings import get_settings
@@ -88,7 +88,7 @@ class DiscussionPayload(BaseModel):
title: str
author: str = "NomadCNA"
city: str = ""
tags: list[str] = []
tags: list[str] = Field(default_factory=list)
excerpt: str = ""
@@ -106,11 +106,36 @@ class ServiceLeadPayload(BaseModel):
note: str = ""
class NotificationCreatePayload(BaseModel):
title: str
body: str = ""
category: str = "system"
priority: str = "normal"
targetUserId: str = ""
audience: str = "all"
actionLabel: str = ""
actionUrl: str = ""
entityType: str = ""
entityId: str = ""
icon: str = "🔔"
channels: list[str] = Field(default_factory=lambda: ["in_app"])
metadata: dict[str, Any] = Field(default_factory=dict)
class NotificationPreferencePayload(BaseModel):
channels: dict[str, bool] = Field(default_factory=dict)
categories: dict[str, bool] = Field(default_factory=dict)
quietHoursStart: str = "22:00"
quietHoursEnd: str = "08:00"
digest: str = "daily"
allowMarketing: bool = False
class RecommendationPayload(BaseModel):
budget: int = 6000
internet: int = 50
climate: str = "any"
tags: list[str] = []
tags: list[str] = Field(default_factory=list)
limit: int = 12
@@ -218,6 +243,147 @@ def public_record(record: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in record.items() if not k.startswith("@")}
def utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000Z")
def default_notification_preferences(user_id: str, email: str = "") -> dict[str, Any]:
return {
"userId": user_id,
"email": email,
"channels": {"in_app": True, "email": False, "wechat": False},
"categories": {
"system": True,
"meetup": True,
"gig": True,
"match": True,
"service": True,
"community": True,
"city": True,
},
"quietHoursStart": "22:00",
"quietHoursEnd": "08:00",
"digest": "daily",
"allowMarketing": False,
}
def normalize_notification_preferences(record: dict[str, Any] | None, user_id: str, email: str = "") -> dict[str, Any]:
defaults = default_notification_preferences(user_id, email)
if not record:
return defaults
public = public_record(record)
return {
**defaults,
**public,
"channels": {**defaults["channels"], **(public.get("channels") or {})},
"categories": {**defaults["categories"], **(public.get("categories") or {})},
}
async def notification_preferences_for_user(user_id: str, user: dict[str, Any] | None = None) -> dict[str, Any]:
record = await pb.first_record("notification_preferences", filter=f'userId={pb_quote(user_id)}')
return normalize_notification_preferences(record, user_id, user.get("email", "") if user else "")
def notification_is_muted(item: dict[str, Any], preferences: dict[str, Any]) -> bool:
category = item.get("category") or "system"
categories = preferences.get("categories") or {}
channels = preferences.get("channels") or {}
item_channels = item.get("channels") or ["in_app"]
category_enabled = bool(categories.get(category, True))
channel_enabled = any(bool(channels.get(channel, False)) for channel in item_channels)
return not category_enabled or not channel_enabled
async def create_notification(
*,
title: str,
body: str = "",
category: str = "system",
priority: str = "normal",
target_user_id: str = "",
audience: str = "all",
action_label: str = "",
action_url: str = "",
entity_type: str = "",
entity_id: str = "",
icon: str = "🔔",
channels: list[str] | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
try:
return await pb.create_record(
"notifications",
{
"title": title,
"body": body,
"category": category,
"priority": priority,
"targetUserId": target_user_id,
"audience": audience,
"actionLabel": action_label,
"actionUrl": action_url,
"entityType": entity_type,
"entityId": entity_id,
"icon": icon,
"status": "published",
"channels": channels or ["in_app"],
"metadata": metadata or {},
},
)
except PocketBaseError:
return None
async def receipt_for(notification_id: str, user_id: str) -> dict[str, Any] | None:
if not user_id:
return None
return await pb.first_record(
"notification_receipts",
filter=f'notificationId={pb_quote(notification_id)} && userId={pb_quote(user_id)}',
)
async def upsert_receipt(notification_id: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any]:
existing = await receipt_for(notification_id, user_id)
base = {"notificationId": notification_id, "userId": user_id, "pinned": False}
if existing:
return await pb.update_record("notification_receipts", existing["id"], payload)
return await pb.create_record("notification_receipts", {**base, **payload})
async def decorated_notifications_for_user(
user_id: str,
preferences: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
preferences = preferences or await notification_preferences_for_user(user_id)
data = await pb.list_records("notifications", filter='status="published"', per_page=100)
notifications = [
public_record(item)
for item in data.get("items", [])
if item.get("audience", "all") == "all" or item.get("targetUserId") == user_id
]
receipts = (await pb.list_records("notification_receipts", filter=f'userId={pb_quote(user_id)}', per_page=200)).get("items", [])
receipt_by_notification = {receipt.get("notificationId"): public_record(receipt) for receipt in receipts}
decorated = []
for item in notifications:
receipt = receipt_by_notification.get(item["id"], {})
archived = bool(receipt.get("archivedAt"))
decorated.append(
{
**item,
"receipt": receipt,
"read": bool(receipt.get("readAt")),
"archived": archived,
"pinned": bool(receipt.get("pinned")),
"muted": notification_is_muted(item, preferences),
}
)
decorated.sort(key=lambda item: (item.get("pinned", False), item.get("created", "")), reverse=True)
return decorated
@app.get("/api/health")
async def health() -> dict[str, Any]:
return {"ok": True, "service": "NomadCNA FastAPI", "pb": settings.pb_url}
@@ -408,6 +574,19 @@ async def join(payload: JoinPayload, request: Request) -> dict[str, Any]:
await pb.update_record("profiles", profile["id"], profile_payload)
else:
await pb.create_record("profiles", profile_payload)
await create_notification(
title="成员资料已更新",
body="你的加入资料已经保存,后续匹配、活动推荐和城市建议会基于这份资料联动。",
category="system",
priority="normal",
target_user_id=user["id"],
audience="user",
action_label="查看资料",
action_url="/join",
entity_type="member_application",
entity_id=record["id"],
icon="👤",
)
return {"ok": True, "record": record, "user_id": user["id"]}
@@ -555,6 +734,171 @@ async def content_detail(slug: str) -> dict[str, Any]:
return {"ok": True, "item": public_record(item), "cities": cities}
@app.get("/api/notifications")
async def list_notifications(
request: Request,
category: str = "all",
status: str = "active",
q: str = "",
muted: str = "include",
) -> dict[str, Any]:
_, user = await current_user(request)
user_id = user["id"] if user else "anonymous"
preferences = await notification_preferences_for_user(user_id, user)
all_items = await decorated_notifications_for_user(user_id, preferences)
items = all_items
if muted == "exclude":
items = [item for item in items if not item.get("muted")]
elif muted == "only":
items = [item for item in items if item.get("muted")]
if category != "all":
items = [item for item in items if item.get("category") == category]
if status == "unread":
items = [item for item in items if not item.get("read") and not item.get("archived") and not item.get("muted")]
elif status == "read":
items = [item for item in items if item.get("read") and not item.get("archived")]
elif status == "archived":
items = [item for item in items if item.get("archived")]
else:
items = [item for item in items if not item.get("archived")]
if q.strip():
needle = q.strip().lower()
items = [
item
for item in items
if needle in str(item.get("title", "")).lower() or needle in str(item.get("body", "")).lower()
]
unread = sum(1 for item in all_items if not item.get("read") and not item.get("archived") and not item.get("muted"))
categories: dict[str, int] = {}
muted_count = 0
for item in all_items:
if not item.get("archived"):
categories[item.get("category", "system")] = categories.get(item.get("category", "system"), 0) + 1
if item.get("muted"):
muted_count += 1
return {
"ok": True,
"items": items,
"unread": unread,
"categories": categories,
"muted": muted_count,
"preferences": preferences,
}
@app.get("/api/notifications/unread-count")
async def notification_unread_count(request: Request) -> dict[str, Any]:
_, user = await current_user(request)
user_id = user["id"] if user else "anonymous"
preferences = await notification_preferences_for_user(user_id, user)
items = await decorated_notifications_for_user(user_id, preferences)
return {"ok": True, "count": sum(1 for item in items if not item.get("read") and not item.get("archived") and not item.get("muted"))}
@app.post("/api/notifications")
async def create_notification_endpoint(payload: NotificationCreatePayload, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
target_user_id = payload.targetUserId or (user["id"] if user and payload.audience == "user" else "")
record = await create_notification(
title=payload.title,
body=payload.body,
category=payload.category,
priority=payload.priority,
target_user_id=target_user_id,
audience=payload.audience,
action_label=payload.actionLabel,
action_url=payload.actionUrl,
entity_type=payload.entityType,
entity_id=payload.entityId,
icon=payload.icon,
channels=payload.channels,
metadata=payload.metadata,
)
if not record:
raise HTTPException(status_code=500, detail="通知创建失败")
return {"ok": True, "record": public_record(record)}
@app.post("/api/notifications/{notification_id}/read")
async def mark_notification_read(notification_id: str, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
user_id = user["id"] if user else "anonymous"
receipt = await upsert_receipt(notification_id, user_id, {"readAt": utc_now()})
return {"ok": True, "receipt": public_record(receipt)}
@app.post("/api/notifications/{notification_id}/unread")
async def mark_notification_unread(notification_id: str, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
user_id = user["id"] if user else "anonymous"
receipt = await upsert_receipt(notification_id, user_id, {"readAt": None, "archivedAt": None})
return {"ok": True, "receipt": public_record(receipt)}
@app.post("/api/notifications/{notification_id}/archive")
async def archive_notification(notification_id: str, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
user_id = user["id"] if user else "anonymous"
receipt = await upsert_receipt(notification_id, user_id, {"archivedAt": utc_now(), "readAt": utc_now()})
return {"ok": True, "receipt": public_record(receipt)}
@app.post("/api/notifications/{notification_id}/restore")
async def restore_notification(notification_id: str, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
user_id = user["id"] if user else "anonymous"
receipt = await upsert_receipt(notification_id, user_id, {"archivedAt": None, "dismissedAt": None})
return {"ok": True, "receipt": public_record(receipt)}
@app.post("/api/notifications/{notification_id}/pin")
async def pin_notification(notification_id: str, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
user_id = user["id"] if user else "anonymous"
existing = await receipt_for(notification_id, user_id)
pinned = not bool(existing and existing.get("pinned"))
receipt = await upsert_receipt(notification_id, user_id, {"pinned": pinned})
return {"ok": True, "receipt": public_record(receipt), "pinned": pinned}
@app.post("/api/notifications/mark-all-read")
async def mark_all_notifications_read(request: Request) -> dict[str, Any]:
_, user = await current_user(request)
user_id = user["id"] if user else "anonymous"
preferences = await notification_preferences_for_user(user_id, user)
items = await decorated_notifications_for_user(user_id, preferences)
count = 0
for item in items:
if not item.get("read") and not item.get("archived") and not item.get("muted"):
await upsert_receipt(item["id"], user_id, {"readAt": utc_now()})
count += 1
return {"ok": True, "count": count}
@app.get("/api/notifications/preferences")
async def notification_preferences(request: Request) -> dict[str, Any]:
_, user = await current_user(request)
user_id = user["id"] if user else "anonymous"
return {"ok": True, "record": await notification_preferences_for_user(user_id, user)}
@app.put("/api/notifications/preferences")
async def update_notification_preferences(payload: NotificationPreferencePayload, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
user_id = user["id"] if user else "anonymous"
record_payload = {
"userId": user_id,
"email": user.get("email", "") if user else "",
**payload.model_dump(),
}
existing = await pb.first_record("notification_preferences", filter=f'userId={pb_quote(user_id)}')
if existing:
record = await pb.update_record("notification_preferences", existing["id"], record_payload)
else:
record = await pb.create_record("notification_preferences", record_payload)
return {"ok": True, "record": public_record(record)}
def city_recommendation_score(city: dict[str, Any], payload: RecommendationPayload) -> tuple[float, list[str]]:
score = float(city.get("overallScore", 0)) * 20
reasons: list[str] = []
@@ -663,6 +1007,19 @@ async def create_service_lead(payload: ServiceLeadPayload, request: Request) ->
"status": "new",
},
)
await create_notification(
title=f"服务咨询已提交:{service.get('title', payload.serviceSlug)}",
body="我们已经记录你的服务咨询,后续可以按城市、预算、路线和会员状态继续跟进。",
category="service",
priority="normal",
target_user_id=user["id"] if user else "",
audience="user" if user else "all",
action_label="查看服务",
action_url="/services",
entity_type="service_lead",
entity_id=record["id"],
icon=service.get("icon") or "🧭",
)
return {"ok": True, "record": public_record(record), "service": public_record(service)}
@@ -709,6 +1066,18 @@ async def create_meetup(payload: dict[str, Any]) -> dict[str, Any]:
payload.setdefault("rsvpCount", 0)
payload.setdefault("attendees", [])
record = await pb.create_record("meetups", payload)
await create_notification(
title=f"新活动已提交:{payload.get('city', '同城')} · {payload.get('venue', '活动地点')}",
body=str(payload.get("description") or "新的同城活动已经进入活动列表,审核后可对外展示。"),
category="meetup",
priority="normal",
audience="all",
action_label="查看活动",
action_url="/meetups",
entity_type="meetup",
entity_id=record["id"],
icon=payload.get("emoji") or "🍹",
)
return {"ok": True, "record": record}
@@ -730,6 +1099,19 @@ async def create_discussion(payload: DiscussionPayload) -> dict[str, Any]:
"views": 0,
},
)
await create_notification(
title=f"新讨论:{payload.title}",
body=payload.excerpt or f"{payload.author}{payload.city or '社区'} 发起了一个新话题。",
category="community",
priority="normal",
audience="all",
action_label="参与讨论",
action_url="/discuss",
entity_type="discussion",
entity_id=record["id"],
icon="🔥",
metadata={"city": payload.city, "tags": payload.tags},
)
return {"ok": True, "record": public_record(record)}
@@ -751,6 +1133,18 @@ async def create_gig(payload: GigPayload, request: Request) -> dict[str, Any]:
"status": "open",
},
)
await create_notification(
title=f"赏金墙新任务:{payload.title}",
body=f"{payload.location} · {payload.category} · ¥{payload.budget}{payload.description}",
category="gig",
priority="high" if payload.budget >= 1000 else "normal",
audience="all",
action_label="查看任务",
action_url="/gigs",
entity_type="gig",
entity_id=record["id"],
icon="💰",
)
return {"ok": True, "record": public_record(record), "user": public_record(user) if user else None}
@@ -772,6 +1166,19 @@ async def apply_gig(gig_id: str, request: Request) -> dict[str, Any]:
},
)
updated = await pb.update_record("gigs", gig_id, {"status": "in_progress"})
await create_notification(
title=f"任务已被接单:{gig.get('title', '赏金任务')}",
body=f"{applicant} 已接下这个任务,任务状态已进入进行中。",
category="gig",
priority="high",
target_user_id=user["id"] if user else "",
audience="user" if user else "all",
action_label="查看赏金墙",
action_url="/gigs",
entity_type="gig_application",
entity_id=application["id"],
icon="",
)
return {
"ok": True,
"message": "已接单",
@@ -792,12 +1199,39 @@ 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})
if payload.action in {"like", "right", "superlike"}:
profile = await pb.first_record("profiles", filter=f'id={pb_quote(payload.profileId)}')
await create_notification(
title=f"已喜欢 {profile.get('name', '一位成员') if profile else '一位成员'}",
body="这条社交动作已经记录,后续可以扩展成互相喜欢、私信和同城约见提醒。",
category="match",
priority="normal",
target_user_id=user_id if user_id != "anonymous" else "",
audience="user" if user_id != "anonymous" else "all",
action_label="继续匹配",
action_url="/dating",
entity_type="swipe",
entity_id=record["id"],
icon="💚",
)
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"})
await create_notification(
title=f"反馈已收到:{payload.title}",
body=payload.content[:220],
category="system",
priority="normal",
audience="all",
action_label="查看反馈页",
action_url="/feedback",
entity_type="feedback",
entity_id=record["id"],
icon="💡",
)
return {"ok": True, "record": record}