'memos'
This commit is contained in:
1
app/db/__init__.py
Normal file
1
app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Database modules
|
||||
753
app/db/community_sqlite.py
Normal file
753
app/db/community_sqlite.py
Normal file
@@ -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": 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
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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",
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
359
app/routers/community.py
Normal file
359
app/routers/community.py
Normal file
@@ -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))
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
116
app/services/community.py
Normal file
116
app/services/community.py
Normal file
@@ -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)
|
||||
BIN
data/community.db
Normal file
BIN
data/community.db
Normal file
Binary file not shown.
BIN
data/community_uploads/1773562558477_89a7aa1d.png
Normal file
BIN
data/community_uploads/1773562558477_89a7aa1d.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
data/community_uploads/1773562715937_0afd6608.png
Normal file
BIN
data/community_uploads/1773562715937_0afd6608.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
data/community_uploads/1773565492766_a3fe972b.png
Normal file
BIN
data/community_uploads/1773565492766_a3fe972b.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
21
docs/COMMUNITY_MEMOS_SCHEMA.md
Normal file
21
docs/COMMUNITY_MEMOS_SCHEMA.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 私密社群 PocketBase 集合 schema
|
||||
|
||||
在 PocketBase 管理后台创建集合 `community_memos`,字段如下:
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| user_id | Text | ✓ | 发布者 user_id(PocketBase 用户 ID 或匿名 user123456) |
|
||||
| content | Text | ✓ | 动态内容,支持 Markdown、#标签、图片 `` |
|
||||
| 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. 保存
|
||||
45
docs/COMMUNITY_SQLITE.md
Normal file
45
docs/COMMUNITY_SQLITE.md
Normal file
@@ -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(图片上传)。
|
||||
Reference in New Issue
Block a user