diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..980755b --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +# Database modules diff --git a/app/db/community_sqlite.py b/app/db/community_sqlite.py new file mode 100644 index 0000000..32d1f22 --- /dev/null +++ b/app/db/community_sqlite.py @@ -0,0 +1,753 @@ +""" +SQLite storage for the private community module. +""" + +from __future__ import annotations + +from collections import Counter +from pathlib import Path +from typing import Optional +import os +import re +import sqlite3 +import time +import uuid + +_ROOT = Path(__file__).resolve().parent.parent.parent +DATA_DIR = Path(os.getenv("COMMUNITY_DATA_DIR", str(_ROOT / "data"))) +DB_PATH = DATA_DIR / "community.db" +UPLOADS_DIR = DATA_DIR / "community_uploads" + +UPVOTE_REACTION = "UPVOTE" + + +def _ensure_dirs() -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + UPLOADS_DIR.mkdir(parents=True, exist_ok=True) + + +def _get_conn() -> sqlite3.Connection: + _ensure_dirs() + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def _now_iso() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()) + + +def _normalize_memo_id(memo_id: str) -> str: + return memo_id.replace("memos/", "") if memo_id.startswith("memos/") else memo_id + + +def _extract_tags(content: str) -> list[str]: + matches = re.findall(r"#[\w\u4e00-\u9fa5]+", content or "") + return list(dict.fromkeys(match.strip("#") for match in matches)) + + +def _extract_attachments(content: str) -> list[dict]: + items = [] + for match in re.finditer(r"!\[([^\]]*)\]\(([^)]+)\)", content or ""): + alt, url = match.group(1).strip(), match.group(2).strip() + if url: + items.append({"url": url, "alt": alt}) + return items + + +def init_schema() -> None: + _ensure_dirs() + conn = _get_conn() + try: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS community_memos ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, + visibility TEXT NOT NULL DEFAULT 'PRIVATE', + state TEXT NOT NULL DEFAULT 'NORMAL', + created TEXT NOT NULL, + updated TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_memos_user ON community_memos(user_id); + CREATE INDEX IF NOT EXISTS idx_memos_state ON community_memos(state); + CREATE INDEX IF NOT EXISTS idx_memos_created ON community_memos(created); + CREATE INDEX IF NOT EXISTS idx_memos_pinned ON community_memos(pinned); + + CREATE TABLE IF NOT EXISTS community_uploads ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + original_name TEXT NOT NULL, + filename TEXT NOT NULL UNIQUE, + content_type TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + url TEXT NOT NULL, + created TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_uploads_user ON community_uploads(user_id); + CREATE INDEX IF NOT EXISTS idx_uploads_created ON community_uploads(created); + + CREATE TABLE IF NOT EXISTS community_comments ( + id TEXT PRIMARY KEY, + memo_id TEXT NOT NULL, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + created TEXT NOT NULL, + updated TEXT NOT NULL, + FOREIGN KEY (memo_id) REFERENCES community_memos(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_comments_memo ON community_comments(memo_id, created); + CREATE INDEX IF NOT EXISTS idx_comments_user ON community_comments(user_id); + + CREATE TABLE IF NOT EXISTS community_reactions ( + id TEXT PRIMARY KEY, + memo_id TEXT NOT NULL, + user_id TEXT NOT NULL, + reaction_type TEXT NOT NULL, + created TEXT NOT NULL, + UNIQUE(memo_id, user_id, reaction_type), + FOREIGN KEY (memo_id) REFERENCES community_memos(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_reactions_memo ON community_reactions(memo_id); + CREATE INDEX IF NOT EXISTS idx_reactions_user ON community_reactions(user_id); + """ + ) + conn.commit() + finally: + conn.close() + + +def _base_memo_from_row(row: sqlite3.Row) -> dict: + content = row["content"] + return { + "id": row["id"], + "user_id": row["user_id"], + "content": content, + "pinned": bool(row["pinned"]), + "visibility": row["visibility"] or "PRIVATE", + "state": row["state"] or "NORMAL", + "created": row["created"], + "updated": row["updated"], + "tags": _extract_tags(content), + "attachments": _extract_attachments(content), + } + + +def _comment_from_row(row: sqlite3.Row) -> dict: + return { + "id": row["id"], + "memo_id": row["memo_id"], + "user_id": row["user_id"], + "content": row["content"], + "created": row["created"], + "updated": row["updated"], + } + + +def _comment_count(conn: sqlite3.Connection, memo_id: str) -> int: + cursor = conn.execute("SELECT COUNT(*) FROM community_comments WHERE memo_id = ?", (memo_id,)) + return int(cursor.fetchone()[0] or 0) + + +def _reaction_count(conn: sqlite3.Connection, memo_id: str) -> int: + cursor = conn.execute( + "SELECT COUNT(*) FROM community_reactions WHERE memo_id = ? AND reaction_type = ?", + (memo_id, UPVOTE_REACTION), + ) + return int(cursor.fetchone()[0] or 0) + + +def _liked_by_user(conn: sqlite3.Connection, memo_id: str, viewer_id: str = "") -> bool: + if not viewer_id: + return False + cursor = conn.execute( + """ + SELECT 1 + FROM community_reactions + WHERE memo_id = ? AND user_id = ? AND reaction_type = ? + LIMIT 1 + """, + (memo_id, viewer_id, UPVOTE_REACTION), + ) + return cursor.fetchone() is not None + + +def list_comments(memo_id: str) -> list[dict]: + uid = _normalize_memo_id(memo_id) + conn = _get_conn() + try: + rows = conn.execute( + """ + SELECT id, memo_id, user_id, content, created, updated + FROM community_comments + WHERE memo_id = ? + ORDER BY created ASC + """, + (uid,), + ).fetchall() + return [_comment_from_row(row) for row in rows] + finally: + conn.close() + + +def _enrich_memo(conn: sqlite3.Connection, memo: dict, viewer_id: str = "", include_comments: bool = False) -> dict: + enriched = dict(memo) + enriched["comment_count"] = _comment_count(conn, memo["id"]) + enriched["reaction_count"] = _reaction_count(conn, memo["id"]) + enriched["liked_by_me"] = _liked_by_user(conn, memo["id"], viewer_id) + if include_comments: + enriched["comments"] = list_comments(memo["id"]) + return enriched + + +def _to_memo_format(memo: dict) -> dict: + payload = { + "name": f"memos/{memo['id']}", + "id": memo["id"], + "creator": f"users/{memo['user_id']}", + "user_id": memo["user_id"], + "content": memo["content"], + "pinned": memo["pinned"], + "visibility": memo["visibility"], + "state": memo["state"], + "create_time": memo["created"], + "update_time": memo["updated"], + "display_time": memo["created"], + "tags": memo["tags"], + "attachments": memo["attachments"], + "comment_count": memo["comment_count"], + "reaction_count": memo["reaction_count"], + "liked_by_me": memo["liked_by_me"], + } + if "comments" in memo: + payload["comments"] = memo["comments"] + return payload + + +def _normalize_memo(memo: dict) -> dict: + payload = { + "id": memo["id"], + "user_id": memo["user_id"], + "content": memo["content"], + "pinned": memo["pinned"], + "visibility": memo["visibility"], + "state": memo["state"], + "created": memo["created"], + "updated": memo["updated"], + "tags": memo["tags"], + "attachments": memo["attachments"], + "comment_count": memo["comment_count"], + "reaction_count": memo["reaction_count"], + "liked_by_me": memo["liked_by_me"], + } + if "comments" in memo: + payload["comments"] = memo["comments"] + return payload + + +def _build_where_clause(state: str = "NORMAL", query: str = "") -> tuple[str, list[str]]: + clauses: list[str] = [] + params: list[str] = [] + + normalized_state = (state or "NORMAL").upper() + if normalized_state != "ALL": + clauses.append("state = ?") + params.append(normalized_state) + + if query: + lowered = query.lower() + clauses.append("LOWER(content) LIKE ?") + params.append(f"%{lowered}%") + + if not clauses: + return "", params + return f"WHERE {' AND '.join(clauses)}", params + + +def list_memos( + page: int = 1, + per_page: int = 50, + state: str = "NORMAL", + order_by: str = "-pinned,-created", + memo_format: bool = False, + query: str = "", + viewer_id: str = "", +) -> dict: + conn = _get_conn() + try: + order_clause = "pinned DESC, created DESC" + if "created" in order_by and "pinned" not in order_by: + order_clause = "created DESC" + + offset = (page - 1) * per_page + where_clause, params = _build_where_clause(state=state, query=query) + + total = int( + conn.execute(f"SELECT COUNT(*) FROM community_memos {where_clause}", params).fetchone()[0] or 0 + ) + + rows = conn.execute( + f""" + SELECT id, user_id, content, pinned, visibility, state, created, updated + FROM community_memos + {where_clause} + ORDER BY {order_clause} + LIMIT ? OFFSET ? + """, + [*params, per_page, offset], + ).fetchall() + + items = [] + for row in rows: + enriched = _enrich_memo(conn, _base_memo_from_row(row), viewer_id=viewer_id) + items.append(_to_memo_format(enriched) if memo_format else _normalize_memo(enriched)) + + next_token = "" if len(items) < per_page else str(page + 1) + return { + "memos": items, + "items": items, + "totalItems": total, + "page": page, + "pageSize": per_page, + "next_page_token": next_token, + } + finally: + conn.close() + + +def get_memo(memo_id: str, viewer_id: str = "", include_comments: bool = True) -> dict | None: + uid = _normalize_memo_id(memo_id) + conn = _get_conn() + try: + row = conn.execute( + """ + SELECT id, user_id, content, pinned, visibility, state, created, updated + FROM community_memos + WHERE id = ? + """, + (uid,), + ).fetchone() + if not row: + return None + + enriched = _enrich_memo( + conn, + _base_memo_from_row(row), + viewer_id=viewer_id, + include_comments=include_comments, + ) + return _to_memo_format(enriched) + finally: + conn.close() + + +def create_memo( + user_id: str, + content: str, + pinned: bool = False, + visibility: str = "PRIVATE", + state: str = "NORMAL", +) -> dict: + content = (content or "").strip() + if not content: + raise ValueError("content cannot be empty") + if len(content) > 10000: + raise ValueError("content is too long") + + memo_id = uuid.uuid4().hex[:16] + now = _now_iso() + + conn = _get_conn() + try: + conn.execute( + """ + INSERT INTO community_memos (id, user_id, content, pinned, visibility, state, created, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (memo_id, user_id, content, 1 if pinned else 0, visibility, state, now, now), + ) + conn.commit() + memo = _enrich_memo( + conn, + { + "id": memo_id, + "user_id": user_id, + "content": content, + "pinned": pinned, + "visibility": visibility, + "state": state, + "created": now, + "updated": now, + "tags": _extract_tags(content), + "attachments": _extract_attachments(content), + }, + viewer_id=user_id, + ) + return _to_memo_format(memo) + finally: + conn.close() + + +def update_memo( + memo_id: str, + user_id: str, + pinned: Optional[bool] = None, + content: Optional[str] = None, + visibility: Optional[str] = None, + state: Optional[str] = None, +) -> dict | None: + uid = _normalize_memo_id(memo_id) + conn = _get_conn() + try: + row = conn.execute("SELECT * FROM community_memos WHERE id = ?", (uid,)).fetchone() + if not row: + return None + + memo = _base_memo_from_row(row) + if memo["user_id"] != user_id: + return None + + updates = [] + params = [] + + if pinned is not None: + updates.append("pinned = ?") + params.append(1 if pinned else 0) + if content is not None: + content = content.strip() + if not content: + raise ValueError("content cannot be empty") + if len(content) > 10000: + raise ValueError("content is too long") + updates.append("content = ?") + params.append(content) + if visibility is not None: + updates.append("visibility = ?") + params.append(visibility) + if state is not None: + updates.append("state = ?") + params.append(state) + + if not updates: + return _to_memo_format(_enrich_memo(conn, memo, viewer_id=user_id)) + + now = _now_iso() + updates.append("updated = ?") + params.append(now) + params.append(uid) + conn.execute(f"UPDATE community_memos SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + + latest_content = content if content is not None else memo["content"] + memo.update( + { + "pinned": pinned if pinned is not None else memo["pinned"], + "content": latest_content, + "visibility": visibility if visibility is not None else memo["visibility"], + "state": state if state is not None else memo["state"], + "updated": now, + "tags": _extract_tags(latest_content), + "attachments": _extract_attachments(latest_content), + } + ) + return _to_memo_format(_enrich_memo(conn, memo, viewer_id=user_id)) + finally: + conn.close() + + +def delete_memo(memo_id: str, user_id: str) -> bool: + uid = _normalize_memo_id(memo_id) + conn = _get_conn() + try: + row = conn.execute("SELECT user_id FROM community_memos WHERE id = ?", (uid,)).fetchone() + if not row or row["user_id"] != user_id: + return False + conn.execute("DELETE FROM community_memos WHERE id = ?", (uid,)) + conn.commit() + return True + finally: + conn.close() + + +def create_comment(memo_id: str, user_id: str, content: str) -> dict: + uid = _normalize_memo_id(memo_id) + content = (content or "").strip() + if not content: + raise ValueError("comment content cannot be empty") + if len(content) > 3000: + raise ValueError("comment content is too long") + + conn = _get_conn() + try: + memo_exists = conn.execute("SELECT 1 FROM community_memos WHERE id = ?", (uid,)).fetchone() + if not memo_exists: + raise ValueError("memo does not exist") + + comment_id = uuid.uuid4().hex[:16] + now = _now_iso() + conn.execute( + """ + INSERT INTO community_comments (id, memo_id, user_id, content, created, updated) + VALUES (?, ?, ?, ?, ?, ?) + """, + (comment_id, uid, user_id, content, now, now), + ) + conn.commit() + return { + "id": comment_id, + "memo_id": uid, + "user_id": user_id, + "content": content, + "created": now, + "updated": now, + } + finally: + conn.close() + + +def delete_comment(comment_id: str, user_id: str) -> bool: + conn = _get_conn() + try: + row = conn.execute( + """ + SELECT c.user_id AS comment_user_id, m.user_id AS memo_user_id + FROM community_comments c + JOIN community_memos m ON m.id = c.memo_id + WHERE c.id = ? + """, + (comment_id,), + ).fetchone() + if not row: + return False + if row["comment_user_id"] != user_id and row["memo_user_id"] != user_id: + return False + conn.execute("DELETE FROM community_comments WHERE id = ?", (comment_id,)) + conn.commit() + return True + finally: + conn.close() + + +def toggle_reaction(memo_id: str, user_id: str, reaction_type: str = UPVOTE_REACTION) -> dict: + uid = _normalize_memo_id(memo_id) + reaction_type = (reaction_type or UPVOTE_REACTION).upper() + conn = _get_conn() + try: + memo_exists = conn.execute("SELECT 1 FROM community_memos WHERE id = ?", (uid,)).fetchone() + if not memo_exists: + raise ValueError("memo does not exist") + + existing = conn.execute( + """ + SELECT id + FROM community_reactions + WHERE memo_id = ? AND user_id = ? AND reaction_type = ? + """, + (uid, user_id, reaction_type), + ).fetchone() + + liked = False + if existing: + conn.execute("DELETE FROM community_reactions WHERE id = ?", (existing["id"],)) + else: + conn.execute( + """ + INSERT INTO community_reactions (id, memo_id, user_id, reaction_type, created) + VALUES (?, ?, ?, ?, ?) + """, + (uuid.uuid4().hex[:16], uid, user_id, reaction_type, _now_iso()), + ) + liked = True + conn.commit() + + return { + "memo_id": uid, + "reaction_type": reaction_type, + "liked": liked, + "reaction_count": _reaction_count(conn, uid), + } + finally: + conn.close() + + +def save_upload(data: bytes, filename: str, content_type: str, user_id: str, base_url: str) -> dict: + _ensure_dirs() + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "jpg" + if ext not in ("jpg", "jpeg", "png", "gif", "webp"): + ext = "jpg" + + upload_id = uuid.uuid4().hex[:16] + stored_name = f"{int(time.time() * 1000)}_{upload_id[:8]}.{ext}" + path = UPLOADS_DIR / stored_name + path.write_bytes(data) + + now = _now_iso() + url = f"{base_url.rstrip('/')}/community/files/{stored_name}" + + conn = _get_conn() + try: + conn.execute( + """ + INSERT INTO community_uploads (id, user_id, original_name, filename, content_type, size, url, created) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (upload_id, user_id, filename, stored_name, content_type, len(data), url, now), + ) + conn.commit() + finally: + conn.close() + + return { + "id": upload_id, + "url": url, + "markdown": f"![{filename}]({url})", + "filename": stored_name, + "original_name": filename, + "content_type": content_type, + "size": len(data), + "created": now, + } + + +def list_uploads(page: int = 1, per_page: int = 24, user_id: str = "") -> dict: + offset = (page - 1) * per_page + conn = _get_conn() + try: + where_clause = "" + params: list[str] = [] + if user_id: + where_clause = "WHERE user_id = ?" + params.append(user_id) + + total = int( + conn.execute(f"SELECT COUNT(*) FROM community_uploads {where_clause}", params).fetchone()[0] or 0 + ) + rows = conn.execute( + f""" + SELECT id, user_id, original_name, filename, content_type, size, url, created + FROM community_uploads + {where_clause} + ORDER BY created DESC + LIMIT ? OFFSET ? + """, + [*params, per_page, offset], + ).fetchall() + + items = [ + { + "id": row["id"], + "user_id": row["user_id"], + "filename": row["filename"], + "original_name": row["original_name"], + "content_type": row["content_type"], + "size": row["size"], + "url": row["url"], + "created": row["created"], + } + for row in rows + ] + + next_token = "" if len(items) < per_page else str(page + 1) + return { + "items": items, + "totalItems": total, + "page": page, + "pageSize": per_page, + "next_page_token": next_token, + } + finally: + conn.close() + + +def get_stats(member_count: int | None = None) -> dict: + conn = _get_conn() + try: + totals = conn.execute( + """ + SELECT + COUNT(*) AS total_memos, + SUM(CASE WHEN state = 'NORMAL' THEN 1 ELSE 0 END) AS normal_memos, + SUM(CASE WHEN state = 'ARCHIVED' THEN 1 ELSE 0 END) AS archived_memos, + SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END) AS pinned_memos, + COUNT(DISTINCT user_id) AS contributors + FROM community_memos + """ + ).fetchone() + + total_uploads = int(conn.execute("SELECT COUNT(*) FROM community_uploads").fetchone()[0] or 0) + total_comments = int(conn.execute("SELECT COUNT(*) FROM community_comments").fetchone()[0] or 0) + total_reactions = int(conn.execute("SELECT COUNT(*) FROM community_reactions").fetchone()[0] or 0) + + today_prefix = _now_iso()[:10] + today_memos = int( + conn.execute("SELECT COUNT(*) FROM community_memos WHERE created LIKE ?", (f"{today_prefix}%",)).fetchone()[0] + or 0 + ) + + top_authors_rows = conn.execute( + """ + SELECT user_id, COUNT(*) AS memo_count, MAX(created) AS latest_created + FROM community_memos + WHERE state = 'NORMAL' + GROUP BY user_id + ORDER BY memo_count DESC, latest_created DESC + LIMIT 6 + """ + ).fetchall() + top_authors = [ + { + "user_id": row["user_id"], + "memo_count": row["memo_count"], + "latest_created": row["latest_created"], + } + for row in top_authors_rows + ] + + tag_counter: Counter[str] = Counter() + for row in conn.execute("SELECT content FROM community_memos WHERE state = 'NORMAL'").fetchall(): + tag_counter.update(_extract_tags(row["content"])) + + top_tags = [{"tag": tag, "count": count} for tag, count in tag_counter.most_common(10)] + + latest_rows = conn.execute( + """ + SELECT id, user_id, content, pinned, visibility, state, created, updated + FROM community_memos + WHERE state = 'NORMAL' + ORDER BY pinned DESC, created DESC + LIMIT 3 + """ + ).fetchall() + latest_memos = [_normalize_memo(_enrich_memo(conn, _base_memo_from_row(row))) for row in latest_rows] + + return { + "stats": { + "members": member_count, + "total_memos": int(totals["total_memos"] or 0), + "normal_memos": int(totals["normal_memos"] or 0), + "archived_memos": int(totals["archived_memos"] or 0), + "pinned_memos": int(totals["pinned_memos"] or 0), + "contributors": int(totals["contributors"] or 0), + "attachments": total_uploads, + "comments": total_comments, + "reactions": total_reactions, + "today_memos": today_memos, + }, + "top_tags": top_tags, + "top_authors": top_authors, + "latest_memos": latest_memos, + } + finally: + conn.close() + + +def get_upload_path(filename: str) -> Path | None: + if not filename or ".." in filename or "/" in filename or "\\" in filename: + return None + path = UPLOADS_DIR / filename + if not path.exists() or not path.is_file(): + return None + return path diff --git a/app/main.py b/app/main.py index 8b3dc59..4ac5a3a 100755 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,7 @@ from .routers import ( legacy_router, common_router, meetup_router, + community_router, ) app = FastAPI( @@ -165,6 +166,7 @@ app.include_router(nomadlms_router) app.include_router(legacy_router) app.include_router(common_router) app.include_router(meetup_router) +app.include_router(community_router) @app.get("/") @@ -180,5 +182,6 @@ async def root(): "nomadlms": "/nomadlms/payh5, ...", "legacy": "/payh5, /payh5/redirect, /zpay_notify, /xorpay_notify, /zpay_order_status", "common": "/submit_meetup_application, /create_user", + "community": "/community/memos, /community/stats, /community/attachments, /community/upload", }, } diff --git a/app/payment/__pycache__/__init__.cpython-312.pyc b/app/payment/__pycache__/__init__.cpython-312.pyc index 9f5317e..751e5ff 100644 Binary files a/app/payment/__pycache__/__init__.cpython-312.pyc and b/app/payment/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/payment/__pycache__/base.cpython-312.pyc b/app/payment/__pycache__/base.cpython-312.pyc index 8bd3013..fd323d0 100644 Binary files a/app/payment/__pycache__/base.cpython-312.pyc and b/app/payment/__pycache__/base.cpython-312.pyc differ diff --git a/app/payment/__pycache__/factory.cpython-312.pyc b/app/payment/__pycache__/factory.cpython-312.pyc index 103f59f..2430877 100644 Binary files a/app/payment/__pycache__/factory.cpython-312.pyc and b/app/payment/__pycache__/factory.cpython-312.pyc differ diff --git a/app/payment/__pycache__/xorpay.cpython-312.pyc b/app/payment/__pycache__/xorpay.cpython-312.pyc index 955ed8c..bb4b95a 100644 Binary files a/app/payment/__pycache__/xorpay.cpython-312.pyc and b/app/payment/__pycache__/xorpay.cpython-312.pyc differ diff --git a/app/payment/__pycache__/zpay.cpython-312.pyc b/app/payment/__pycache__/zpay.cpython-312.pyc index f5b00c9..0a31e87 100644 Binary files a/app/payment/__pycache__/zpay.cpython-312.pyc and b/app/payment/__pycache__/zpay.cpython-312.pyc differ diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 99afa37..52065d5 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -13,6 +13,7 @@ from .nomadlms import router as nomadlms_router from .legacy import router as legacy_router from .common import router as common_router from .meetup import router as meetup_router +from .community import router as community_router __all__ = [ "nomadvip_router", @@ -22,4 +23,5 @@ __all__ = [ "legacy_router", "common_router", "meetup_router", + "community_router", ] diff --git a/app/routers/__pycache__/__init__.cpython-312.pyc b/app/routers/__pycache__/__init__.cpython-312.pyc index 1848da2..95fd57c 100644 Binary files a/app/routers/__pycache__/__init__.cpython-312.pyc and b/app/routers/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/routers/__pycache__/_digital_base.cpython-312.pyc b/app/routers/__pycache__/_digital_base.cpython-312.pyc index 3e137b1..5cafe26 100644 Binary files a/app/routers/__pycache__/_digital_base.cpython-312.pyc and b/app/routers/__pycache__/_digital_base.cpython-312.pyc differ diff --git a/app/routers/__pycache__/cnomadcna.cpython-312.pyc b/app/routers/__pycache__/cnomadcna.cpython-312.pyc index 43305c6..e204276 100644 Binary files a/app/routers/__pycache__/cnomadcna.cpython-312.pyc and b/app/routers/__pycache__/cnomadcna.cpython-312.pyc differ diff --git a/app/routers/__pycache__/common.cpython-312.pyc b/app/routers/__pycache__/common.cpython-312.pyc index 8592782..f6ebe17 100644 Binary files a/app/routers/__pycache__/common.cpython-312.pyc and b/app/routers/__pycache__/common.cpython-312.pyc differ diff --git a/app/routers/__pycache__/digital.cpython-312.pyc b/app/routers/__pycache__/digital.cpython-312.pyc index eb292c0..f9cd874 100644 Binary files a/app/routers/__pycache__/digital.cpython-312.pyc and b/app/routers/__pycache__/digital.cpython-312.pyc differ diff --git a/app/routers/__pycache__/legacy.cpython-312.pyc b/app/routers/__pycache__/legacy.cpython-312.pyc index e36aa3a..34c4906 100644 Binary files a/app/routers/__pycache__/legacy.cpython-312.pyc and b/app/routers/__pycache__/legacy.cpython-312.pyc differ diff --git a/app/routers/__pycache__/nomadlms.cpython-312.pyc b/app/routers/__pycache__/nomadlms.cpython-312.pyc index 4b25e44..07b2170 100644 Binary files a/app/routers/__pycache__/nomadlms.cpython-312.pyc and b/app/routers/__pycache__/nomadlms.cpython-312.pyc differ diff --git a/app/routers/__pycache__/nomadvip.cpython-312.pyc b/app/routers/__pycache__/nomadvip.cpython-312.pyc index 1fe4d2b..d5bd854 100644 Binary files a/app/routers/__pycache__/nomadvip.cpython-312.pyc and b/app/routers/__pycache__/nomadvip.cpython-312.pyc differ diff --git a/app/routers/community.py b/app/routers/community.py new file mode 100644 index 0000000..968cb34 --- /dev/null +++ b/app/routers/community.py @@ -0,0 +1,359 @@ +""" +Private community API. +""" + +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Any + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse +import requests + +from ..config import BASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL +from ..db import community_sqlite as sqlite_db +from ..services import community as community_svc + +ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} +MAX_IMAGE_SIZE = 10 * 1024 * 1024 +SITE_ID = "vip" + +router = APIRouter(prefix="/community", tags=["community"]) +logger = logging.getLogger(__name__) + + +def _get_pb_admin_token() -> str | None: + if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD: + return None + + base = (PB_URL or "").rstrip("/") + for path in ( + "/api/collections/_superusers/auth-with-password", + "/api/admins/auth-with-password", + ): + try: + response = requests.post( + f"{base}{path}", + json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, + timeout=10, + ) + if response.status_code == 200: + return response.json().get("token") + except Exception: + continue + return None + + +def _pb_escape(value: str) -> str: + return str(value).replace("\\", "\\\\").replace('"', '\\"') + + +def _uid_has_vip(uid: str) -> bool: + uid = (uid or "").strip() + if not uid: + return False + + token = _get_pb_admin_token() + if not token: + return False + + base = (PB_URL or "").rstrip("/") + headers = {"Authorization": f"Bearer {token}"} + safe_uid = _pb_escape(uid) + + try: + response = requests.get( + f"{base}/api/collections/site_vip/records", + params={"filter": f'user_id = "{safe_uid}" && site_id = "{SITE_ID}"', "perPage": 1}, + headers=headers, + timeout=10, + ) + if response.status_code == 200: + items = response.json().get("items") or [] + if items: + record = items[0] + expires = record.get("expires_at") or "" + if not expires: + return True + try: + expires_at = datetime.fromisoformat(expires.replace("Z", "+00:00")) + now = datetime.now(expires_at.tzinfo) if expires_at.tzinfo else datetime.now() + return expires_at.timestamp() >= now.timestamp() + except Exception: + return True + except Exception: + pass + + try: + response = requests.get( + f"{base}/api/collections/payments/records", + params={"filter": f'user_id = "{safe_uid}" && type = "vip"', "sort": "-created", "perPage": 1}, + headers=headers, + timeout=10, + ) + if response.status_code == 200 and (response.json().get("items") or []): + return True + except Exception: + pass + + return False + + +def _count_vip_members() -> int | None: + token = _get_pb_admin_token() + if not token: + return None + + base = (PB_URL or "").rstrip("/") + headers = {"Authorization": f"Bearer {token}"} + + try: + response = requests.get( + f"{base}/api/collections/site_vip/records", + params={"filter": f'site_id = "{SITE_ID}"', "perPage": 1}, + headers=headers, + timeout=10, + ) + if response.status_code == 200: + payload = response.json() + return int(payload.get("totalItems") or len(payload.get("items") or [])) + except Exception: + return None + + return None + + +def _require_vip_user(user_id: str, detail: str = "Only VIP members can access the private community.") -> str: + normalized = (user_id or "").strip() + if not normalized: + raise HTTPException(status_code=401, detail="Missing user_id") + if not _uid_has_vip(normalized): + raise HTTPException(status_code=403, detail=detail) + return normalized + + +def _extract_payload(item: dict[str, Any]) -> dict[str, Any]: + nested = item.get("memo") or item.get("comment") or item.get("reaction") + if isinstance(nested, dict): + return nested + return item + + +@router.get("/health") +async def community_health(): + return {"ok": True, "service": "community"} + + +@router.get("/files/{filename:path}") +async def serve_upload_file(filename: str): + path = sqlite_db.get_upload_path(filename) + if path is None: + raise HTTPException(status_code=404, detail="File not found.") + return FileResponse(path, filename=filename) + + +@router.get("/stats") +async def get_stats(user_id: str = ""): + normalized = _require_vip_user(user_id) + member_count = _count_vip_members() + return {"ok": True, "user_id": normalized, **community_svc.get_stats(member_count=member_count)} + + +@router.get("/attachments") +async def list_attachments(user_id: str = "", page: int = 1, per_page: int = 24): + _require_vip_user(user_id) + per_page = min(max(1, per_page), 60) + page = max(1, page) + return {"ok": True, **community_svc.list_uploads(page=page, per_page=per_page)} + + +@router.get("/memos") +async def list_memos( + user_id: str = "", + page: int = 1, + per_page: int = 50, + page_size: int = 0, + page_token: str = "", + state: str = "NORMAL", + order_by: str = "-pinned,-created", + memo_format: str = "1", + query: str = "", +): + normalized = _require_vip_user(user_id) + per_page = per_page or page_size or 50 + per_page = min(max(1, per_page), 100) + page = max(1, int(page_token) if page_token and page_token.isdigit() else page) + + try: + result = community_svc.list_memos( + page=page, + per_page=per_page, + state=(state or "NORMAL").upper(), + order_by=order_by or "-pinned,-created", + memo_format=memo_format != "0", + query=(query or "").strip(), + viewer_id=normalized, + ) + return {"ok": True, **result} + except RuntimeError as exc: + logger.error("community list_memos config error: %s", exc) + raise HTTPException(status_code=503, detail=str(exc)) + except Exception as exc: + logger.error("community list_memos error: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.get("/memos/{memo_id}") +async def get_memo(memo_id: str, user_id: str = ""): + normalized = _require_vip_user(user_id) + result = community_svc.get_memo(memo_id, viewer_id=normalized, include_comments=True) + if result is None: + raise HTTPException(status_code=404, detail="Memo not found.") + return {"ok": True, "memo": result} + + +@router.post("/memos") +async def create_memo(item: dict[str, Any]): + payload = _extract_payload(item) + user_id = _require_vip_user(payload.get("user_id") or item.get("user_id"), "Only VIP members can publish posts.") + content = str(payload.get("content") or item.get("content") or "").strip() + pinned = bool(payload.get("pinned") if payload.get("pinned") is not None else item.get("pinned")) + visibility = str(payload.get("visibility") or item.get("visibility") or "PRIVATE").upper() + state = str(payload.get("state") or item.get("state") or "NORMAL").upper() + + try: + memo_record = community_svc.create_memo( + user_id=user_id, + content=content, + pinned=pinned, + visibility=visibility, + state=state, + ) + return {"ok": True, "memo": memo_record} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except RuntimeError as exc: + logger.error("community create_memo config error: %s", exc) + raise HTTPException(status_code=503, detail=str(exc)) + except Exception as exc: + logger.error("community create_memo error: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.patch("/memos/{memo_id}") +async def update_memo(memo_id: str, item: dict[str, Any]): + payload = _extract_payload(item) + user_id = _require_vip_user(payload.get("user_id") or item.get("user_id"), "Only VIP members can update posts.") + + pinned = payload.get("pinned") if payload.get("pinned") is not None else item.get("pinned") + content = payload.get("content") if payload.get("content") is not None else item.get("content") + visibility = payload.get("visibility") or item.get("visibility") + state = payload.get("state") or item.get("state") + + try: + result = community_svc.update_memo( + memo_id.replace("memos/", ""), + user_id, + pinned=bool(pinned) if pinned is not None else None, + content=str(content) if content is not None else None, + visibility=str(visibility).upper() if visibility else None, + state=str(state).upper() if state else None, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + if result is None: + raise HTTPException(status_code=404, detail="Memo not found or permission denied.") + return {"ok": True, "memo": result} + + +@router.delete("/memos/{memo_id}") +async def delete_memo(memo_id: str, user_id: str = ""): + normalized = _require_vip_user(user_id, "Only VIP members can delete posts.") + ok = community_svc.delete_memo(memo_id, normalized) + if not ok: + raise HTTPException(status_code=404, detail="Memo not found or permission denied.") + return {"ok": True} + + +@router.get("/memos/{memo_id}/comments") +async def get_comments(memo_id: str, user_id: str = ""): + _require_vip_user(user_id) + return {"ok": True, "comments": community_svc.list_comments(memo_id)} + + +@router.post("/memos/{memo_id}/comments") +async def create_comment(memo_id: str, item: dict[str, Any]): + payload = _extract_payload(item) + user_id = _require_vip_user( + payload.get("user_id") or item.get("user_id"), + "Only VIP members can reply in the private community.", + ) + content = str(payload.get("content") or item.get("content") or "").strip() + + try: + comment = community_svc.create_comment(memo_id, user_id, content) + return {"ok": True, "comment": comment} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + logger.error("community create_comment error: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.delete("/comments/{comment_id}") +async def delete_comment(comment_id: str, user_id: str = ""): + normalized = _require_vip_user(user_id, "Only VIP members can manage comments.") + ok = community_svc.delete_comment(comment_id, normalized) + if not ok: + raise HTTPException(status_code=404, detail="Comment not found or permission denied.") + return {"ok": True} + + +@router.post("/memos/{memo_id}/reactions") +async def toggle_reaction(memo_id: str, item: dict[str, Any]): + payload = _extract_payload(item) + user_id = _require_vip_user( + payload.get("user_id") or item.get("user_id"), + "Only VIP members can react in the private community.", + ) + reaction_type = str(payload.get("reaction_type") or item.get("reaction_type") or sqlite_db.UPVOTE_REACTION) + + try: + reaction = community_svc.toggle_reaction(memo_id, user_id, reaction_type) + return {"ok": True, "reaction": reaction} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + logger.error("community toggle_reaction error: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.post("/upload") +async def upload_image(user_id: str = Form(""), file: UploadFile = File(...)): + normalized = _require_vip_user(user_id, "Only VIP members can upload images.") + + if not file.content_type or file.content_type.lower() not in ALLOWED_IMAGE_TYPES: + raise HTTPException(status_code=400, detail="Only jpeg, png, gif, and webp images are supported.") + + data = await file.read() + if len(data) > MAX_IMAGE_SIZE: + raise HTTPException(status_code=400, detail="Image size must be 10MB or smaller.") + + try: + base = (BASE_URL or "http://localhost:8000").rstrip("/") + result = community_svc.upload_image( + data=data, + filename=file.filename or "image", + content_type=file.content_type or "image/jpeg", + user_id=normalized, + base_url=base, + ) + return {"ok": True, **result} + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) + except Exception as exc: + logger.error("community upload error: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/app/sdk/__pycache__/__init__.cpython-312.pyc b/app/sdk/__pycache__/__init__.cpython-312.pyc index ecf6064..64d5f8c 100644 Binary files a/app/sdk/__pycache__/__init__.cpython-312.pyc and b/app/sdk/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/sdk/__pycache__/memos.cpython-312.pyc b/app/sdk/__pycache__/memos.cpython-312.pyc index 48d6188..79f09bb 100644 Binary files a/app/sdk/__pycache__/memos.cpython-312.pyc and b/app/sdk/__pycache__/memos.cpython-312.pyc differ diff --git a/app/sdk/__pycache__/minio.cpython-312.pyc b/app/sdk/__pycache__/minio.cpython-312.pyc index 9c2d776..26baa09 100644 Binary files a/app/sdk/__pycache__/minio.cpython-312.pyc and b/app/sdk/__pycache__/minio.cpython-312.pyc differ diff --git a/app/sdk/__pycache__/pocketbase.cpython-312.pyc b/app/sdk/__pycache__/pocketbase.cpython-312.pyc index 7335262..a316c62 100644 Binary files a/app/sdk/__pycache__/pocketbase.cpython-312.pyc and b/app/sdk/__pycache__/pocketbase.cpython-312.pyc differ diff --git a/app/sdk/payment/__pycache__/__init__.cpython-312.pyc b/app/sdk/payment/__pycache__/__init__.cpython-312.pyc index 218827e..c74dc61 100644 Binary files a/app/sdk/payment/__pycache__/__init__.cpython-312.pyc and b/app/sdk/payment/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/services/__pycache__/__init__.cpython-312.pyc b/app/services/__pycache__/__init__.cpython-312.pyc index 89f9b90..08b5507 100644 Binary files a/app/services/__pycache__/__init__.cpython-312.pyc and b/app/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/services/__pycache__/memos.cpython-312.pyc b/app/services/__pycache__/memos.cpython-312.pyc index 8f1b1b9..3be7f44 100644 Binary files a/app/services/__pycache__/memos.cpython-312.pyc and b/app/services/__pycache__/memos.cpython-312.pyc differ diff --git a/app/services/__pycache__/payment.cpython-312.pyc b/app/services/__pycache__/payment.cpython-312.pyc index d6c0306..791667f 100644 Binary files a/app/services/__pycache__/payment.cpython-312.pyc and b/app/services/__pycache__/payment.cpython-312.pyc differ diff --git a/app/services/__pycache__/pb.cpython-312.pyc b/app/services/__pycache__/pb.cpython-312.pyc index 66a33e9..d4bc2c4 100644 Binary files a/app/services/__pycache__/pb.cpython-312.pyc and b/app/services/__pycache__/pb.cpython-312.pyc differ diff --git a/app/services/community.py b/app/services/community.py new file mode 100644 index 0000000..1cadf46 --- /dev/null +++ b/app/services/community.py @@ -0,0 +1,116 @@ +""" +Private community service layer. +""" + +from __future__ import annotations + +import logging +import os + +from ..config import BASE_URL +from ..db import community_sqlite as sqlite_db + +logger = logging.getLogger(__name__) + +try: + sqlite_db.init_schema() + logger.info("community: SQLite initialized at %s", sqlite_db.DB_PATH) +except Exception as exc: + logger.warning("community SQLite init failed: %s", exc) + + +def list_memos( + page: int = 1, + per_page: int = 50, + state: str = "NORMAL", + order_by: str = "-pinned,-created", + memo_format: bool = False, + query: str = "", + viewer_id: str = "", +) -> dict: + return sqlite_db.list_memos( + page=page, + per_page=per_page, + state=state, + order_by=order_by, + memo_format=memo_format, + query=query, + viewer_id=viewer_id, + ) + + +def get_memo(memo_id: str, viewer_id: str = "", include_comments: bool = True) -> dict | None: + return sqlite_db.get_memo(memo_id, viewer_id=viewer_id, include_comments=include_comments) + + +def create_memo( + user_id: str, + content: str, + pinned: bool = False, + visibility: str = "PRIVATE", + state: str = "NORMAL", +) -> dict: + return sqlite_db.create_memo( + user_id=user_id, + content=content, + pinned=pinned, + visibility=visibility, + state=state, + ) + + +def update_memo( + memo_id: str, + user_id: str, + pinned: bool | None = None, + content: str | None = None, + visibility: str | None = None, + state: str | None = None, +) -> dict | None: + return sqlite_db.update_memo( + memo_id, + user_id, + pinned=pinned, + content=content, + visibility=visibility, + state=state, + ) + + +def delete_memo(memo_id: str, user_id: str) -> bool: + return sqlite_db.delete_memo(memo_id, user_id) + + +def create_comment(memo_id: str, user_id: str, content: str) -> dict: + return sqlite_db.create_comment(memo_id, user_id, content) + + +def list_comments(memo_id: str) -> list[dict]: + return sqlite_db.list_comments(memo_id) + + +def delete_comment(comment_id: str, user_id: str) -> bool: + return sqlite_db.delete_comment(comment_id, user_id) + + +def toggle_reaction(memo_id: str, user_id: str, reaction_type: str = sqlite_db.UPVOTE_REACTION) -> dict: + return sqlite_db.toggle_reaction(memo_id, user_id, reaction_type) + + +def upload_image( + data: bytes, + filename: str, + content_type: str, + user_id: str, + base_url: str = "", +) -> dict: + base = base_url or (os.getenv("BASE_URL") or BASE_URL or "http://localhost:8000") + return sqlite_db.save_upload(data, filename, content_type, user_id, base) + + +def list_uploads(page: int = 1, per_page: int = 24, user_id: str = "") -> dict: + return sqlite_db.list_uploads(page=page, per_page=per_page, user_id=user_id) + + +def get_stats(member_count: int | None = None) -> dict: + return sqlite_db.get_stats(member_count=member_count) diff --git a/data/community.db b/data/community.db new file mode 100644 index 0000000..9c8f62f Binary files /dev/null and b/data/community.db differ diff --git a/data/community_uploads/1773562558477_89a7aa1d.png b/data/community_uploads/1773562558477_89a7aa1d.png new file mode 100644 index 0000000..da77684 Binary files /dev/null and b/data/community_uploads/1773562558477_89a7aa1d.png differ diff --git a/data/community_uploads/1773562715937_0afd6608.png b/data/community_uploads/1773562715937_0afd6608.png new file mode 100644 index 0000000..da77684 Binary files /dev/null and b/data/community_uploads/1773562715937_0afd6608.png differ diff --git a/data/community_uploads/1773565492766_a3fe972b.png b/data/community_uploads/1773565492766_a3fe972b.png new file mode 100644 index 0000000..da77684 Binary files /dev/null and b/data/community_uploads/1773565492766_a3fe972b.png differ diff --git a/docs/COMMUNITY_MEMOS_SCHEMA.md b/docs/COMMUNITY_MEMOS_SCHEMA.md new file mode 100644 index 0000000..91e81ae --- /dev/null +++ b/docs/COMMUNITY_MEMOS_SCHEMA.md @@ -0,0 +1,21 @@ +# 私密社群 PocketBase 集合 schema + +在 PocketBase 管理后台创建集合 `community_memos`,字段如下: + +| 字段名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| user_id | Text | ✓ | 发布者 user_id(PocketBase 用户 ID 或匿名 user123456) | +| content | Text | ✓ | 动态内容,支持 Markdown、#标签、图片 `![](url)` | +| pinned | Bool | | 是否置顶,默认 false | +| visibility | Text | | 可见性:PRIVATE/PROTECTED/PUBLIC,默认 PRIVATE | +| state | Text | | 状态:NORMAL/ARCHIVED,默认 NORMAL | + +- 集合名:`community_memos` +- 列表/查看权限:仅服务端 admin 操作,前端通过 payjsapi `/community/memos` 接口访问 +- 创建/更新/删除:仅 VIP 用户,由 payjsapi 校验 + +创建步骤: +1. 登录 PocketBase 管理后台 +2. Collections → New collection → 名称 `community_memos` +3. 添加字段:user_id (Text), content (Editor 或 Text), pinned (Bool) +4. 保存 diff --git a/docs/COMMUNITY_SQLITE.md b/docs/COMMUNITY_SQLITE.md new file mode 100644 index 0000000..7e0dbb0 --- /dev/null +++ b/docs/COMMUNITY_SQLITE.md @@ -0,0 +1,45 @@ +# 社群模块 SQLite 存储 + +默认使用 SQLite,无需 PocketBase、MinIO 等外部依赖。图片上传保存到本地目录。 + +## 配置 + +| 环境变量 | 说明 | 默认 | +|----------|------|------| +| COMMUNITY_STORAGE | `sqlite` \| `pocketbase` | `sqlite` | +| COMMUNITY_DATA_DIR | 数据目录(含 db、uploads) | `./data` | +| BASE_URL | 用于拼接图片 URL | 见主配置 | + +## 文件结构 + +``` +data/ +├── community.db # SQLite 数据库 +└── community_uploads/ # 上传图片 + └── 1731234567_abc123.jpg +``` + +## 表结构(community_memos) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | TEXT | 主键 | +| user_id | TEXT | 发布者 | +| content | TEXT | 内容 | +| pinned | INTEGER | 置顶 0/1 | +| visibility | TEXT | PRIVATE/PROTECTED/PUBLIC | +| state | TEXT | NORMAL/ARCHIVED | +| created | TEXT | ISO 时间 | +| updated | TEXT | ISO 时间 | + +## 启动时自动建表 + +`app/services/community.py` 加载时自动执行 `init_schema()`,无需手动迁移。 + +## 切换 PocketBase + +```bash +COMMUNITY_STORAGE=pocketbase +``` + +需配置 PocketBase 和 MinIO(图片上传)。