Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d098f5f8 | ||
|
|
fcf990c133 | ||
|
|
8f9bf47713 | ||
|
|
32bfc89792 | ||
|
|
4b6955fc93 | ||
|
|
4c618af91a | ||
|
|
0326ab2875 | ||
|
|
4634b16cf8 |
23
.env
Normal file
23
.env
Normal file
@@ -0,0 +1,23 @@
|
||||
# ========== 生产环境配置 ==========
|
||||
|
||||
BASE_URL=https://api.hackrobot.cn
|
||||
PAYMENT_PROVIDER=zpay
|
||||
|
||||
# ZPAY(请填入实际值)
|
||||
ZPAY_PID=2025121809351743
|
||||
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
|
||||
|
||||
# PocketBase(check-user、ensure-user 必需,否则报「服务配置错误」)
|
||||
PB_URL=https://pocketbase.hackrobot.cn
|
||||
PB_ADMIN_EMAIL=xiaoshuang.eric@gmail.com
|
||||
PB_ADMIN_PASSWORD=Xiao4669805@
|
||||
|
||||
# Memos(nomadvip 支付成功后创建账号)
|
||||
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
|
||||
MEMOS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg
|
||||
|
||||
# MinIO(可选)
|
||||
MINIO_ENDPOINT=minioweb.hackrobot.cn
|
||||
MINIO_PORT=443
|
||||
MINIO_BUCKET=hackrobot
|
||||
MINIO_UPLOAD_PREFIX=joins
|
||||
17
.env.local
Normal file
17
.env.local
Normal file
@@ -0,0 +1,17 @@
|
||||
# 本地调试 - 端口 8007、zpay 渠道
|
||||
PORT=8007
|
||||
PAYMENT_PROVIDER=zpay
|
||||
BASE_URL=https://api.hackrobot.cn
|
||||
|
||||
# PocketBase(支付回调、nomadvip/digital/cnomadcna 共用)
|
||||
PB_URL=https://pocketbase.hackrobot.cn
|
||||
PB_ADMIN_EMAIL=xiaoshuang.eric@gmail.com
|
||||
PB_ADMIN_PASSWORD=Xiao4669805@
|
||||
|
||||
# Memos(nomadvip 支付成功后创建账号)
|
||||
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
|
||||
MEMOS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg
|
||||
|
||||
# ZPAY
|
||||
ZPAY_PID=2025121809351743
|
||||
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,5 +9,5 @@ __pycache__/
|
||||
*.py[cod]
|
||||
venv/
|
||||
.venv/
|
||||
.env
|
||||
.env.local
|
||||
# .env
|
||||
# .env.local
|
||||
|
||||
14
README.md
14
README.md
@@ -84,13 +84,19 @@ PAYMENT_PROVIDER=zpay
|
||||
|
||||
## 线上「支付失败」排查
|
||||
|
||||
本地正常、Ubuntu 部署后显示支付失败,常见原因:
|
||||
本地正常、Ubuntu 部署后 api.hackrobot.cn 调用支付失败,常见原因:
|
||||
|
||||
1. **BASE_URL 配置错误**:必须为 ZPAY 可公网访问的地址。错误示例:`http://127.0.0.1:8700`、`http://localhost`。正确:`https://api.hackrobot.cn`(域名需解析到本机,nginx 反向代理 8700 端口)。
|
||||
1. **BASE_URL 配置错误**:必须为 ZPAY 可公网访问的地址。错误示例:`http://127.0.0.1:8700`、`http://localhost`。正确:`https://api.hackrobot.cn`(域名需解析到本机,nginx 反向代理对应端口)。
|
||||
|
||||
2. **服务器无法访问 zpayz.cn**:防火墙、DNS 或网络限制。访问 `https://你的域名/health?check_zpay=1` 自检,若 `zpayz_reachable: false` 则需开放出站或检查网络。
|
||||
2. **服务器无法访问 zpayz.cn**:防火墙、DNS 或网络限制。访问 `https://api.hackrobot.cn/health?check_zpay=1` 自检:
|
||||
- `zpayz_reachable: false` → 开放出站或检查网络
|
||||
- `zpayz_error` 含 "SSL" → 执行 `apt install ca-certificates` 后重启
|
||||
|
||||
3. **启动时控制台有 BASE_URL 警告**:说明配置了内网地址,需修改 `.env` 中的 `BASE_URL` 为公网地址后重启。
|
||||
3. **client_ip 内网导致 ZPAY 风控拒单**:前端(digital/cnomadcna/nomadvip)通过 nginx 代理时,需配置 `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`,否则 payjsapi 收到 127.0.0.1 会触发风控。已做兼容:内网 IP 时自动省略 clientip 参数。
|
||||
|
||||
4. **启动时控制台有 BASE_URL 警告**:说明配置了内网地址,需修改 `.env` 中的 `BASE_URL` 为公网地址后重启。
|
||||
|
||||
5. **查看详细错误**:支付失败时,payjsapi 会输出 `ZPAY create_order_api 失败` 日志,含 status/code/msg,便于定位。
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
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
|
||||
23
app/main.py
23
app/main.py
@@ -30,6 +30,7 @@ from .routers import (
|
||||
legacy_router,
|
||||
common_router,
|
||||
meetup_router,
|
||||
community_router,
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
@@ -118,15 +119,31 @@ async def startup_event():
|
||||
|
||||
@app.get("/health")
|
||||
async def health(check_zpay: str = ""):
|
||||
"""健康检查。check_zpay=1 时额外检测 zpayz.cn 连通性"""
|
||||
"""健康检查。check_zpay=1 时额外检测 zpayz.cn 连通性(含 SSL、mapi 接口)"""
|
||||
out = {"status": "ok", "service": "payjsapi", "base_url": BASE_URL}
|
||||
if str(check_zpay).lower() in ("1", "true", "yes"):
|
||||
# 1) 检测 zpayz.cn 首页
|
||||
try:
|
||||
r = requests.get("https://zpayz.cn", timeout=5)
|
||||
out["zpayz_reachable"] = r.status_code in (200, 301, 302, 404)
|
||||
except Exception as e:
|
||||
except requests.exceptions.SSLError as e:
|
||||
out["zpayz_reachable"] = False
|
||||
out["zpayz_error"] = f"SSL 错误: {e}(Ubuntu 请执行: apt install ca-certificates)"
|
||||
except requests.RequestException as e:
|
||||
out["zpayz_reachable"] = False
|
||||
out["zpayz_error"] = str(e)
|
||||
# 2) 检测 mapi 接口(实际支付调用地址)
|
||||
if out.get("zpayz_reachable"):
|
||||
try:
|
||||
r2 = requests.post("https://zpayz.cn/mapi.php", data={"pid": "test"}, timeout=5)
|
||||
out["zpayz_mapi_reachable"] = True
|
||||
out["zpayz_mapi_status"] = r2.status_code
|
||||
except requests.exceptions.SSLError as e:
|
||||
out["zpayz_mapi_reachable"] = False
|
||||
out["zpayz_mapi_error"] = f"SSL: {e}"
|
||||
except requests.RequestException as e:
|
||||
out["zpayz_mapi_reachable"] = False
|
||||
out["zpayz_mapi_error"] = str(e)
|
||||
return out
|
||||
|
||||
|
||||
@@ -149,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("/")
|
||||
@@ -164,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.
@@ -5,12 +5,27 @@ ZPAY 支付渠道实现
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .base import PaymentProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_internal_ip(ip: Optional[str]) -> bool:
|
||||
"""判断是否为内网 IP,ZPAY 可能拒收导致 Ubuntu 部署失败"""
|
||||
if not ip or not ip.strip():
|
||||
return True
|
||||
ip = ip.strip()
|
||||
if ip in ("127.0.0.1", "localhost", "::1"):
|
||||
return True
|
||||
if ip.startswith("10.") or ip.startswith("192.168.") or ip.startswith("172.16."):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ZPayProvider(PaymentProvider):
|
||||
"""ZPAY 支付(易支付兼容接口)"""
|
||||
@@ -64,8 +79,10 @@ class ZPayProvider(PaymentProvider):
|
||||
"money": total_fee_yuan,
|
||||
"return_url": return_url or notify_url,
|
||||
}
|
||||
if client_ip:
|
||||
if client_ip and not _is_internal_ip(client_ip):
|
||||
params["clientip"] = client_ip
|
||||
elif client_ip and _is_internal_ip(client_ip):
|
||||
logger.warning("ZPAY create_order 跳过内网 client_ip=%s", client_ip)
|
||||
if extra and extra.get("param"):
|
||||
params["param"] = str(extra["param"])
|
||||
params["sign_type"] = "MD5"
|
||||
@@ -98,6 +115,14 @@ class ZPayProvider(PaymentProvider):
|
||||
适用于需要二维码或 H5 跳转的场景
|
||||
"""
|
||||
channel_type = "wxpay" if pay_type.lower() in ("wx", "wechat", "wxpay") else "alipay"
|
||||
effective_ip = (client_ip or "").strip() or "127.0.0.1"
|
||||
# 内网 IP(127.0.0.1/10.x/192.168.x)可能导致 ZPAY 风控拒单,Ubuntu 部署时常见
|
||||
if _is_internal_ip(effective_ip):
|
||||
logger.warning(
|
||||
"ZPAY create_order_api client_ip=%s 为内网地址,已省略 clientip 参数(避免风控拒单)",
|
||||
effective_ip,
|
||||
)
|
||||
effective_ip = "" # 不传 clientip,由 ZPAY 从连接获取
|
||||
params = {
|
||||
"pid": self.pid,
|
||||
"type": channel_type,
|
||||
@@ -105,9 +130,10 @@ class ZPayProvider(PaymentProvider):
|
||||
"notify_url": notify_url,
|
||||
"name": name,
|
||||
"money": total_fee_yuan,
|
||||
"clientip": client_ip or "127.0.0.1",
|
||||
"device": device,
|
||||
}
|
||||
if effective_ip:
|
||||
params["clientip"] = effective_ip
|
||||
if return_url:
|
||||
params["return_url"] = return_url
|
||||
if extra and extra.get("param"):
|
||||
@@ -117,7 +143,15 @@ class ZPayProvider(PaymentProvider):
|
||||
|
||||
try:
|
||||
resp = requests.post(self.MAPI_URL, data=params, timeout=15)
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.error("ZPAY SSL 错误(Ubuntu 可能 CA 证书不全): %s", e, exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"provider": self.name,
|
||||
"msg": f"连接 zpayz.cn SSL 失败: {e}。Ubuntu 请安装 ca-certificates: apt install ca-certificates",
|
||||
}
|
||||
except requests.RequestException as e:
|
||||
logger.error("ZPAY 连接失败: %s", e, exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"provider": self.name,
|
||||
@@ -160,10 +194,18 @@ class ZPayProvider(PaymentProvider):
|
||||
"O_id": result.get("O_id"),
|
||||
"trade_no": result.get("trade_no"),
|
||||
}
|
||||
err_msg = result.get("msg", resp.text)
|
||||
logger.error(
|
||||
"ZPAY create_order_api 失败 status=%s code=%s msg=%s resp_text=%s",
|
||||
resp.status_code,
|
||||
code,
|
||||
err_msg,
|
||||
resp.text[:500] if resp.text else "",
|
||||
)
|
||||
return {
|
||||
"status": "error",
|
||||
"provider": self.name,
|
||||
"msg": result.get("msg", resp.text),
|
||||
"msg": err_msg,
|
||||
}
|
||||
|
||||
def verify_notify(self, data: Dict[str, Any]) -> bool:
|
||||
|
||||
@@ -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.
@@ -69,6 +69,10 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
||||
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||
timestamp = int(time.time())
|
||||
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||||
return_url = item.get("return_url")
|
||||
if return_url and "order_id=" not in return_url and "out_trade_no=" not in return_url:
|
||||
sep = "&" if "?" in return_url else "?"
|
||||
return_url = f"{return_url}{sep}order_id={order_id}"
|
||||
type_map = {
|
||||
"book": "购买书籍",
|
||||
"meetup": "数字游民社区报名",
|
||||
@@ -84,7 +88,7 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
||||
total_fee_yuan=total_fee_yuan,
|
||||
pay_type=channel,
|
||||
notify_url=notify_url,
|
||||
return_url=item.get("return_url"),
|
||||
return_url=return_url,
|
||||
client_ip=item.get("client_ip"),
|
||||
)
|
||||
_safe_log(
|
||||
@@ -94,7 +98,7 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
||||
order_id,
|
||||
provider.name,
|
||||
notify_url,
|
||||
item.get("return_url") or "",
|
||||
return_url or "",
|
||||
)
|
||||
if provider.name == "xorpay":
|
||||
return {
|
||||
@@ -139,13 +143,16 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
||||
pay_type = item.get("type", "unknown").lower()
|
||||
return_url = item.get("return_url")
|
||||
device = item.get("device", "pc")
|
||||
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||
timestamp = int(time.time())
|
||||
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||||
if return_url and "order_id=" not in return_url and "out_trade_no=" not in return_url:
|
||||
sep = "&" if "?" in return_url else "?"
|
||||
return_url = f"{return_url}{sep}order_id={order_id}"
|
||||
client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1")
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
client_ip = forwarded.split(",")[0].strip()
|
||||
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||
timestamp = int(time.time())
|
||||
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||||
type_map = {
|
||||
"book": "购买书籍",
|
||||
"meetup": "数字游民社区报名",
|
||||
|
||||
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))
|
||||
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