diff --git a/README.md b/README.md index 8bf0906..f106a41 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ - 顶栏:**探索 / 连接 / 成长** 三组下拉,**EN · 主题 · 通知** 固定在右上 - `Ctrl+K` 空态只给三环捷径;次要页面要搜索才出现 - 工具箱默认精选 +「展开全部」,不再当全站站点地图 +- **连接闭环:** 活动报名需登录(可取消);匹配 / 报名 / 讨论回复 / 赏金申请会写入通知中心;可选 ntfy 推送(`NTFY_ENABLED` + 设置里打开「推送」) +- **订阅:** 写入 PocketBase;配置 `LISTMONK_*` 后同步 Listmonk,未配置时文案如实提示「已登记」 ## 技术栈 diff --git a/backend/app/config.py b/backend/app/config.py index 4a70c8c..bbb0ef1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,6 +24,12 @@ class Settings(BaseSettings): ntfy_enabled: bool = False ntfy_url: str = "http://127.0.0.1:2586" + # Optional Listmonk newsletter (leave empty = store only) + listmonk_url: str = "" + listmonk_user: str = "" + listmonk_password: str = "" + listmonk_list_id: int = 1 + # Payment (from env on server) dev_auto_pay: bool = False payment_provider: str = "zpay" diff --git a/backend/app/routers/api.py b/backend/app/routers/api.py index 3f99e36..7582804 100644 --- a/backend/app/routers/api.py +++ b/backend/app/routers/api.py @@ -262,16 +262,30 @@ async def get_meetup(meetup_id: str): @router.post("/meetups/rsvp") async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Header(None)): - user_id = None - if authorization and authorization.startswith("Bearer "): - user = get_user_by_token(authorization[7:]) - if user: - user_id = user["id"] + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(401, "请先登录后再报名,以便保留名额") + user = get_user_by_token(authorization[7:]) + if not user: + raise HTTPException(401, "请先登录后再报名") + user_id = user["id"] res = community_store.rsvp_meetup(body.meetup_id, user_id) if not res.get("ok"): if res.get("error") == "not_found": raise HTTPException(404, "活动不存在") raise HTTPException(400, "活动已满员") + + meetup = community_store.get_meetup(body.meetup_id) or {} + organizer_id = meetup.get("organizer_id") or "" + if organizer_id and organizer_id != user_id: + from app.services.notify import notify_user + + notify_user( + organizer_id, + "新的活动报名", + f"{user.get('name', '游民')} 报名了「{meetup.get('title', '活动')}」", + f"/meetups", + "meetup", + ) return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]} diff --git a/backend/app/routers/community.py b/backend/app/routers/community.py index a700de9..3226908 100644 --- a/backend/app/routers/community.py +++ b/backend/app/routers/community.py @@ -38,6 +38,18 @@ async def post_reply( reply = community_store.add_reply(discussion_id, user["id"], user["name"], body.content, body.author_emoji) if not reply: raise HTTPException(404, "讨论不存在") + discussion = community_store.get_discussion(discussion_id, increment_view=False) or {} + author_id = discussion.get("author_id") or "" + if author_id and author_id != user["id"]: + from app.services.notify import notify_user + + notify_user( + author_id, + "讨论有新回复", + f"{user['name']} 回复了「{discussion.get('title', '话题')}」", + f"/community/{discussion_id}", + "community", + ) return {"success": True, "reply": reply} @@ -87,7 +99,18 @@ async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | Non res = community_store.apply_gig(gig_id, user["id"], user["name"], body.message) if not res.get("ok"): raise HTTPException(400, res.get("error", "申请失败")) - return res + poster_id = res.get("poster_id") or "" + if poster_id and poster_id != user["id"]: + from app.services.notify import notify_user + + notify_user( + poster_id, + "赏金任务有新申请", + f"{user['name']} 申请了「{res.get('title') or '任务'}」", + "/gigs", + "gig", + ) + return {"ok": True, "message": res.get("message", "申请已提交")} @router.get("/notifications", response_model=list[NotificationItem]) diff --git a/backend/app/services/community_store.py b/backend/app/services/community_store.py index a5bed69..ea13416 100644 --- a/backend/app/services/community_store.py +++ b/backend/app/services/community_store.py @@ -412,7 +412,12 @@ def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict: "gig_applications", {"gigId": g["id"], "userId": user_id, "applicant": user_name, "message": message, "status": "pending"}, ) - return {"ok": True, "message": "申请已提交"} + return { + "ok": True, + "message": "申请已提交", + "poster_id": g.get("posterId", ""), + "title": g.get("title", ""), + } # ── Notifications ────────────────────────────────────────────────────── @@ -727,9 +732,15 @@ def _json_create_gig(user_id: str, user_name: str, payload: dict) -> dict: def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict: _jload() + gig = next((g for g in (_j.get("gigs") or []) if g.get("id") == gig_id), {}) or {} _j.setdefault("gig_apps", []).append({"gig_id": gig_id, "user_id": user_id, "user_name": user_name, "message": message}) _jsave() - return {"ok": True, "message": "申请已提交"} + return { + "ok": True, + "message": "申请已提交", + "poster_id": gig.get("poster_id", ""), + "title": gig.get("title", ""), + } def _json_add_notification(user_id: str, title: str, body: str, link: str, category: str) -> None: diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py new file mode 100644 index 0000000..ad39c1e --- /dev/null +++ b/backend/app/services/notify.py @@ -0,0 +1,58 @@ +"""Unified in-app + optional ntfy push notifications.""" +from __future__ import annotations + +import asyncio +import re + +from app.config import settings +from app.services import community_store, platform_store +from app.services.ntfy_push import send_push + +_PREF_KEY = { + "match": "match", + "meetup": "meetup", + "community": "community", + "gig": "community", + "general": None, +} + + +def _topic_for(user_id: str) -> str: + safe = re.sub(r"[^a-zA-Z0-9_-]", "", user_id)[:48] or "guest" + return f"nomadro-{safe}" + + +def notify_user( + user_id: str, + title: str, + body: str, + link: str = "", + category: str = "general", +) -> bool: + """Persist inbox notification; optionally fire ntfy when push prefs allow.""" + if not user_id: + return False + prefs = platform_store.get_notif_prefs(user_id) + pref_key = _PREF_KEY.get(category) + if pref_key is not None and not prefs.get(pref_key, True): + return False + + community_store.add_notification(user_id, title, body, link, category) + + if prefs.get("push") and settings.ntfy_enabled: + click = link if link.startswith("http") else f"{settings.site_base_url.rstrip('/')}{link or '/notifications'}" + try: + loop = asyncio.get_running_loop() + loop.create_task( + send_push( + _topic_for(user_id), + title, + body, + tags=["nomadro", category], + click_url=click, + ) + ) + except RuntimeError: + # Outside async context — best-effort sync skip; inbox already saved + pass + return True diff --git a/backend/app/services/platform_store.py b/backend/app/services/platform_store.py index 7f2bef1..fdc3622 100644 --- a/backend/app/services/platform_store.py +++ b/backend/app/services/platform_store.py @@ -7,6 +7,7 @@ from datetime import datetime, timezone from pathlib import Path from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb +from app.config import settings _JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json" _j: dict = {} @@ -117,19 +118,55 @@ def add_report(reporter_id: str | None, target_type: str, target_id: str, reason def subscribe_newsletter(email: str, source: str = "site") -> dict: + """Save subscription locally; sync to Listmonk when configured.""" + email = email.strip().lower() + synced = False + if settings.listmonk_url and settings.listmonk_user: + try: + import httpx + + base = settings.listmonk_url.rstrip("/") + auth = (settings.listmonk_user, settings.listmonk_password) + payload = { + "email": email, + "name": email.split("@")[0], + "status": "enabled", + "lists": [settings.listmonk_list_id], + } + with httpx.Client(timeout=8.0) as client: + r = client.post(f"{base}/api/subscribers", json=payload, auth=auth) + if r.status_code in (200, 409): + synced = True + elif r.status_code == 400 and "exists" in (r.text or "").lower(): + synced = True + except Exception: + synced = False + if use_pb(): dup = safe_first("subscriptions", filter=f"email={q(email)}") if dup: - return {"ok": True, "message": "已订阅"} + return { + "ok": True, + "message": "已订阅" if synced else "已登记订阅(邮件系统稍后开通)", + "synced": synced, + } safe_create("subscriptions", {"email": email}) - return {"ok": True, "message": "订阅成功"} + return { + "ok": True, + "message": "订阅成功" if synced else "已登记,邮件推送开通后会同步发送", + "synced": synced, + } _jload() for n in _j.get("newsletter") or []: if n["email"] == email: - return {"ok": True, "message": "已订阅"} + return {"ok": True, "message": "已订阅", "synced": synced} _j.setdefault("newsletter", []).append({"email": email, "source": source, "created_at": _now()}) _jsave() - return {"ok": True, "message": "订阅成功"} + return { + "ok": True, + "message": "订阅成功" if synced else "已登记,邮件推送开通后会同步发送", + "synced": synced, + } def get_notif_prefs(user_id: str) -> dict: diff --git a/backend/app/services/pocketbase.py b/backend/app/services/pocketbase.py index c6d0fe0..30cd056 100644 --- a/backend/app/services/pocketbase.py +++ b/backend/app/services/pocketbase.py @@ -139,9 +139,8 @@ class PocketBaseService: result = await self.create_record("subscriptions", {"email": email}) if result: - return True, "订阅成功!欢迎加入 nomadro 社区 🎉" - # fallback: always succeed in demo mode - return True, "订阅成功!欢迎加入 nomadro 社区 🎉" + return True, "已登记订阅。邮件推送开通后会同步发送。" + return False, "订阅暂时不可用,请稍后再试" def _map_destination(self, r: dict) -> dict: return { diff --git a/backend/app/services/social_store.py b/backend/app/services/social_store.py index 9f73b31..ea55e46 100644 --- a/backend/app/services/social_store.py +++ b/backend/app/services/social_store.py @@ -396,17 +396,21 @@ def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dic if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id): matched = _ensure_match(user_id, profile, intent) if matched: - from app.services import community_store + from app.services.notify import notify_user my_prof = get_public_profile(user_id) or {} peer_name = profile.get("name", "游民") conv_id = matched.get("conversationId", "") link = f"/chat/{conv_id}" if conv_id else "/chat" - community_store.add_notification(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link) + notify_user(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link, "match") peer_uid = profile.get("userId") if peer_uid: - community_store.add_notification( - peer_uid, "匹配成功 🎉", f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧", link + notify_user( + peer_uid, + "匹配成功 🎉", + f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧", + link, + "match", ) return {"ok": True, "matched": bool(matched), "match": matched} diff --git a/deploy/nomadro-api.env.example b/deploy/nomadro-api.env.example index cde1731..0488ab6 100644 --- a/deploy/nomadro-api.env.example +++ b/deploy/nomadro-api.env.example @@ -24,6 +24,12 @@ S3_UPLOAD_PREFIX=nomadweb NTFY_ENABLED=true NTFY_URL=http://127.0.0.1:2586 +# Optional Listmonk (leave empty = store subscriptions only) +LISTMONK_URL= +LISTMONK_USER= +LISTMONK_PASSWORD= +LISTMONK_LIST_ID=1 + ZPAY_PID= ZPAY_KEY= ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3939ecd..ca83c3d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -11004,3 +11004,13 @@ img { max-width: 100%; display: block; } grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +.meetups-login-hint { + margin: 8px 0 0; + font-size: 0.9rem; + color: var(--text-secondary); +} +.meetups-login-hint a { + color: var(--accent-3, #4ecdc4); + text-decoration: underline; +} diff --git a/frontend/src/components/MeetupsClient.tsx b/frontend/src/components/MeetupsClient.tsx index f22d80b..8328057 100644 --- a/frontend/src/components/MeetupsClient.tsx +++ b/frontend/src/components/MeetupsClient.tsx @@ -2,6 +2,7 @@ import { useMemo, useState, useEffect } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { api } from "@/lib/api"; import { useToast } from "@/lib/toast"; import { useAuth } from "@/lib/auth"; @@ -26,11 +27,16 @@ export default function MeetupsClient({ meetups }: Props) { const rings = useRingSteps(); const { toast } = useToast(); const { token } = useAuth(); + const router = useRouter(); const [mode, setMode] = useState<"all" | Meetup["mode"]>("all"); const [rsvpIds, setRsvpIds] = useState>(new Set()); + const [busyId, setBusyId] = useState(null); useEffect(() => { - if (!token) return; + if (!token) { + setRsvpIds(new Set()); + return; + } api.getMyRsvps(token).then((r) => setRsvpIds(new Set(r.ids))).catch(() => {}); }, [token]); @@ -40,17 +46,35 @@ export default function MeetupsClient({ meetups }: Props) { }, [meetups, mode]); const handleRsvp = async (meetup: Meetup) => { - if (rsvpIds.has(meetup.id)) { - toast(t.meetups.alreadyRsvp, "info"); + if (!token) { + toast("登录后报名,名额才会保留", "info"); + router.push(`/login?next=${encodeURIComponent("/meetups")}`); return; } + if (rsvpIds.has(meetup.id)) { + setBusyId(meetup.id); + try { + await api.cancelMeetupRsvp(token, meetup.id); + const next = new Set(rsvpIds); + next.delete(meetup.id); + setRsvpIds(next); + toast("已取消报名", "success"); + } catch { + toast(t.meetups.rsvpFail, "error"); + } finally { + setBusyId(null); + } + return; + } + setBusyId(meetup.id); try { - const res = await api.rsvpMeetup(meetup.id, undefined, token || undefined); - const next = new Set(rsvpIds).add(meetup.id); - setRsvpIds(next); + const res = await api.rsvpMeetup(meetup.id, undefined, token); + setRsvpIds(new Set(rsvpIds).add(meetup.id)); toast(res.message || t.meetups.rsvpOk, "success"); } catch { toast(t.meetups.rsvpFail, "error"); + } finally { + setBusyId(null); } }; @@ -67,6 +91,11 @@ export default function MeetupsClient({ meetups }: Props) { {t.meetups.tag}

{t.meetups.title}

{t.meetups.subtitle}

+ {!token && ( +

+ 登录 后报名,名额会同步到你的账号 +

+ )}
@@ -84,57 +113,58 @@ export default function MeetupsClient({ meetups }: Props) {
- {filtered.map((m) => ( -
-
- {m.emoji} -
-

{m.title}

-

- {MODE_LABELS[m.mode]} · {m.city} · {m.date} {m.time} -

+ {filtered.map((m) => { + const joined = rsvpIds.has(m.id); + return ( +
+
+ {m.emoji} +
+

{m.title}

+

+ {MODE_LABELS[m.mode]} · {m.city} · {m.date} {m.time} +

+
-
-

{m.description}

-

📍 {m.venue}

-
- {m.tags.map((tag) => ( - {tag} - ))} -
-
- - {m.rsvp_count}/{m.max_attendees} {t.meetups.rsvp} - - {m.organizer} -
- {(m.mode === "online" || m.mode === "hybrid") && ( - - {t.meetups.liveBtn} - - )} - +

{m.description}

+

📍 {m.venue}

+
+ {m.tags.map((tag) => ( + {tag} + ))}
-
- {m.destination_slug && ( - - {t.meetups.viewCity} → - - )} -
- ))} +
+ + {m.rsvp_count}/{m.max_attendees} {t.meetups.rsvp} + + {m.organizer} +
+ {(m.mode === "online" || m.mode === "hybrid") && ( + + {t.meetups.liveBtn} + + )} + +
+
+ {m.destination_slug && ( + + {t.meetups.viewCity} → + + )} + + ); + })}
- {filtered.length === 0 && ( -

{t.meetups.empty}

- )} + {filtered.length === 0 &&

{t.meetups.empty}

} diff --git a/frontend/src/components/NotificationSettingsClient.tsx b/frontend/src/components/NotificationSettingsClient.tsx index 6dab80e..dfcc407 100644 --- a/frontend/src/components/NotificationSettingsClient.tsx +++ b/frontend/src/components/NotificationSettingsClient.tsx @@ -52,7 +52,7 @@ export default function NotificationSettingsClient() {

{t.notifSettings.title}

- {(["match", "meetup", "community", "email", "marketing"] as const).map((k) => ( + {(["match", "meetup", "community", "push", "email", "marketing"] as const).map((k) => (