Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d098f5f8 |
@@ -18,9 +18,7 @@ ZPAY_PID=2025121809351743
|
||||
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
|
||||
|
||||
# PocketBase(check-user、ensure-user、支付回调、nomadvip/digital/cnomadcna 共用)
|
||||
# 公网 PB_URL 会被忽略并回落本机 8090;本地无 PocketBase 时复制 .env.local(含 PB_INTERNAL_URL)
|
||||
PB_URL=https://pocketbase.hackrobot.cn
|
||||
# PB_INTERNAL_URL=https://pocketbase.hackrobot.cn
|
||||
PB_ADMIN_EMAIL=xiaoshuang.eric@gmail.com
|
||||
PB_ADMIN_PASSWORD=Xiao4669805@
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
# ========== 本地调试(python run.py,端口 8007)==========
|
||||
# 无本机 PocketBase 时:必须用 PB_INTERNAL_URL 指向上线(config 会忽略公网 PB_URL)
|
||||
# 本地调试 - 端口 8007、zpay 渠道
|
||||
PORT=8007
|
||||
PAYMENT_PROVIDER=zpay
|
||||
BASE_URL=https://api.hackrobot.cn
|
||||
|
||||
# PocketBase(支付回调、nomadvip/digital/cnomadcna 共用)
|
||||
PB_URL=https://pocketbase.hackrobot.cn
|
||||
PB_INTERNAL_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
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -11,8 +11,3 @@ venv/
|
||||
.venv/
|
||||
# .env
|
||||
# .env.local
|
||||
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
@@ -27,27 +27,8 @@ XORPAY_CASHIER_URL = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cas
|
||||
ZPAY_PID = os.getenv("ZPAY_PID", "2025121809351743")
|
||||
ZPAY_KEY = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89")
|
||||
|
||||
# PocketBase(支持 PB_ADMIN_* 或 POCKETBASE_*)
|
||||
# 为了避免线上环境遗留 PB_URL=公网域名导致慢查询,默认强制优先本机。
|
||||
# 本地无 PocketBase、需连线上 PB 时:在 .env.local 设置 PB_INTERNAL_URL=https://pocketbase.hackrobot.cn
|
||||
# (该变量优先级最高,且不改变下方对 PB_URL 的解析规则)
|
||||
_pb_internal = os.getenv("PB_INTERNAL_URL") or os.getenv("POCKETBASE_INTERNAL_URL")
|
||||
_pb_explicit = os.getenv("PB_URL") or os.getenv("POCKETBASE_URL")
|
||||
if _pb_internal:
|
||||
PB_URL = _pb_internal
|
||||
elif _pb_explicit and (
|
||||
"127.0.0.1" in _pb_explicit
|
||||
or "localhost" in _pb_explicit
|
||||
or _pb_explicit.startswith("http://10.")
|
||||
or _pb_explicit.startswith("https://10.")
|
||||
or _pb_explicit.startswith("http://172.")
|
||||
or _pb_explicit.startswith("https://172.")
|
||||
or _pb_explicit.startswith("http://192.168.")
|
||||
or _pb_explicit.startswith("https://192.168.")
|
||||
):
|
||||
PB_URL = _pb_explicit
|
||||
else:
|
||||
PB_URL = "http://127.0.0.1:8090"
|
||||
# PocketBase(支持 PB_ADMIN_* 或 POCKETBASE_*,空值时用默认)
|
||||
PB_URL = os.getenv("PB_URL") or os.getenv("POCKETBASE_URL") or "https://pocketbase.hackrobot.cn"
|
||||
PB_ADMIN_EMAIL = (
|
||||
os.getenv("PB_ADMIN_EMAIL") or os.getenv("POCKETBASE_EMAIL") or "xiaoshuang.eric@gmail.com"
|
||||
)
|
||||
@@ -62,7 +43,7 @@ MEMOS_TOKEN = os.getenv(
|
||||
"eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg",
|
||||
)
|
||||
|
||||
# 回调地址(用于生成 notify_url)。线上 api.nomadyt.com 时需设置 BASE_URL=https://api.nomadyt.com
|
||||
# 回调地址(用于生成 notify_url)
|
||||
BASE_URL = os.getenv("BASE_URL", "https://api.hackrobot.cn")
|
||||
|
||||
# MinIO 对象存储
|
||||
|
||||
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
|
||||
15
app/main.py
15
app/main.py
@@ -30,9 +30,8 @@ from .routers import (
|
||||
legacy_router,
|
||||
common_router,
|
||||
meetup_router,
|
||||
chatbot_router,
|
||||
community_router,
|
||||
)
|
||||
from .routers.salon_meetup import router as salon_meetup_router
|
||||
|
||||
app = FastAPI(
|
||||
title="PayJS API",
|
||||
@@ -116,14 +115,6 @@ def _check_base_url():
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
_check_base_url()
|
||||
# 预热 PocketBase 连接,减少首个业务请求冷启动延迟
|
||||
try:
|
||||
from .services.pb import get_pb_client
|
||||
|
||||
get_pb_client()
|
||||
logging.info("PocketBase client warmup done")
|
||||
except Exception as error:
|
||||
logging.warning("PocketBase client warmup failed: %s", error)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -175,8 +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(salon_meetup_router)
|
||||
app.include_router(chatbot_router)
|
||||
app.include_router(community_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -192,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",
|
||||
},
|
||||
}
|
||||
|
||||
BIN
app/payment/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/payment/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/payment/__pycache__/base.cpython-312.pyc
Normal file
BIN
app/payment/__pycache__/base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/payment/__pycache__/factory.cpython-312.pyc
Normal file
BIN
app/payment/__pycache__/factory.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/payment/__pycache__/xorpay.cpython-312.pyc
Normal file
BIN
app/payment/__pycache__/xorpay.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/payment/__pycache__/zpay.cpython-312.pyc
Normal file
BIN
app/payment/__pycache__/zpay.cpython-312.pyc
Normal file
Binary file not shown.
@@ -246,5 +246,4 @@ class ZPayProvider(PaymentProvider):
|
||||
status = int(result.get("status", 0))
|
||||
except (ValueError, TypeError):
|
||||
status = 0
|
||||
money = result.get("money") or result.get("pay_price") or ""
|
||||
return {"paid": status == 1, "status": status, "pay_price": str(money) if money else ""}
|
||||
return {"paid": status == 1, "status": status}
|
||||
|
||||
@@ -13,7 +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 .chatbot import router as chatbot_router
|
||||
from .community import router as community_router
|
||||
|
||||
__all__ = [
|
||||
"nomadvip_router",
|
||||
@@ -23,5 +23,5 @@ __all__ = [
|
||||
"legacy_router",
|
||||
"common_router",
|
||||
"meetup_router",
|
||||
"chatbot_router",
|
||||
"community_router",
|
||||
]
|
||||
|
||||
BIN
app/routers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/_digital_base.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/_digital_base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/cnomadcna.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/cnomadcna.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/common.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/common.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/digital.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/digital.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/legacy.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/legacy.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/nomadlms.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/nomadlms.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/nomadvip.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/nomadvip.cpython-312.pyc
Normal file
Binary file not shown.
@@ -2,7 +2,6 @@
|
||||
digital/cnomadcna/nomadlms 共享的支付逻辑
|
||||
根据 prefix 生成对应 notify_path,业务互不影响
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
@@ -77,7 +76,6 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
||||
type_map = {
|
||||
"book": "购买书籍",
|
||||
"meetup": "数字游民社区报名",
|
||||
"salon": "沙龙报名茶歇费",
|
||||
"video": "视频解锁",
|
||||
"vip": "VIP会员充值",
|
||||
}
|
||||
@@ -158,7 +156,6 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
||||
type_map = {
|
||||
"book": "购买书籍",
|
||||
"meetup": "数字游民社区报名",
|
||||
"salon": "沙龙报名茶歇费",
|
||||
"video": "视频解锁",
|
||||
"vip": "VIP会员充值",
|
||||
}
|
||||
@@ -218,46 +215,43 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
||||
if not order_id:
|
||||
return {"paid": False, "error": "missing order_id"}
|
||||
|
||||
def _query_pb() -> dict | None:
|
||||
try:
|
||||
from ..services.pb import get_pb_client
|
||||
pb = get_pb_client()
|
||||
records = pb.collection("payments").get_list(1, 1, {"filter": f'order_id = "{order_id}"'})
|
||||
if records.items:
|
||||
return {"paid": True, "source": "pocketbase"}
|
||||
except Exception as error:
|
||||
_safe_log(logging.WARNING, "%s order_status PocketBase failed: %s", project_name, error)
|
||||
return None
|
||||
try:
|
||||
from ..services.pb import get_pb_client
|
||||
|
||||
def _query_xorpay() -> dict | None:
|
||||
try:
|
||||
xorpay = get_payment_provider_by_name("xorpay")
|
||||
r = xorpay.query_order_status(order_id, timeout=6)
|
||||
if r.get("paid"):
|
||||
return {"paid": True, "source": "xorpay", "status": r.get("status", "")}
|
||||
except Exception as error:
|
||||
_safe_log(logging.WARNING, "%s order_status XorPay failed: %s", project_name, error)
|
||||
return None
|
||||
pb = get_pb_client()
|
||||
records = pb.collection("payments").get_list(
|
||||
1, 1, {"filter": f'order_id = "{order_id}"'}
|
||||
)
|
||||
if records.items:
|
||||
return {"paid": True, "source": "pocketbase"}
|
||||
except Exception as error:
|
||||
_safe_log(logging.WARNING, "%s order_status PocketBase failed: %s", project_name, error)
|
||||
|
||||
def _query_zpay() -> dict | None:
|
||||
try:
|
||||
zpay = get_payment_provider_by_name("zpay")
|
||||
if isinstance(zpay, ZPayProvider):
|
||||
r = zpay.query_order_status(order_id, timeout=6)
|
||||
if r.get("paid"):
|
||||
return {"paid": True, "source": "zpay", "status": r.get("status", "")}
|
||||
except Exception as error:
|
||||
_safe_log(logging.WARNING, "%s order_status ZPAY failed: %s", project_name, error)
|
||||
return None
|
||||
try:
|
||||
xorpay = get_payment_provider_by_name("xorpay")
|
||||
xorpay_result = xorpay.query_order_status(order_id, timeout=8)
|
||||
if xorpay_result.get("paid"):
|
||||
return {
|
||||
"paid": True,
|
||||
"source": "xorpay",
|
||||
"status": xorpay_result.get("status", ""),
|
||||
}
|
||||
except Exception as error:
|
||||
_safe_log(logging.WARNING, "%s order_status XorPay failed: %s", project_name, error)
|
||||
|
||||
try:
|
||||
zpay = get_payment_provider_by_name("zpay")
|
||||
if isinstance(zpay, ZPayProvider):
|
||||
zpay_result = zpay.query_order_status(order_id, timeout=8)
|
||||
if zpay_result.get("paid"):
|
||||
return {
|
||||
"paid": True,
|
||||
"source": "zpay",
|
||||
"status": zpay_result.get("status", ""),
|
||||
}
|
||||
except Exception as error:
|
||||
_safe_log(logging.WARNING, "%s order_status ZPAY failed: %s", project_name, error)
|
||||
|
||||
# 并行查询 PB、ZPAY、XorPay,任一返回 paid 即立即返回(PC 扫码用 ZPAY,微信用 XorPay)
|
||||
pb_task = asyncio.to_thread(_query_pb)
|
||||
xorpay_task = asyncio.to_thread(_query_xorpay)
|
||||
zpay_task = asyncio.to_thread(_query_zpay)
|
||||
results = await asyncio.gather(pb_task, xorpay_task, zpay_task)
|
||||
for r in results:
|
||||
if r:
|
||||
return r
|
||||
return {"paid": False}
|
||||
|
||||
@router.post("/xorpay_notify")
|
||||
|
||||
@@ -1,683 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import httpx
|
||||
from Crypto.Cipher import AES
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
|
||||
from ..config import PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL
|
||||
|
||||
router = APIRouter(prefix="/chatbot", tags=["chatbot"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_processed_callback_cache: dict[str, int] = {}
|
||||
|
||||
|
||||
OPEN_API_PROFILES = {
|
||||
"eric": {
|
||||
"name": "Eric在旅行",
|
||||
"APP_ID": "fXYWvGfq04uMScP",
|
||||
"TOKEN": "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn",
|
||||
"ENCODING_AES_KEY": "1wJZiJm8URfRjkHu1QfIVDeNFc2jbzzeCZqvhQecsCx",
|
||||
},
|
||||
"xidu": {
|
||||
"name": "异度世界",
|
||||
"APP_ID": "LTKfSFeK0h8TRLM",
|
||||
"TOKEN": "64DbvAd5m7YOJ1XslyMgJ9V42jDIUH",
|
||||
"ENCODING_AES_KEY": "KCZGtPDjqFZnBuApvG3xqhvHUMnqFHbC5cnD23HKACX",
|
||||
},
|
||||
}
|
||||
ACTIVE_PROFILE = (os.getenv("CHATBOT_ACTIVE_PROFILE") or "eric").strip().lower()
|
||||
if ACTIVE_PROFILE not in OPEN_API_PROFILES:
|
||||
ACTIVE_PROFILE = "eric"
|
||||
APP_ID = OPEN_API_PROFILES[ACTIVE_PROFILE]["APP_ID"]
|
||||
TOKEN = OPEN_API_PROFILES[ACTIVE_PROFILE]["TOKEN"]
|
||||
ENCODING_AES_KEY = OPEN_API_PROFILES[ACTIVE_PROFILE]["ENCODING_AES_KEY"]
|
||||
|
||||
PM_SYNC_AUTHTOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4"
|
||||
PM_SYNC_DEFAULT_WXBOT_BID = "75021053883a9090c67d5becde7f4b0a"
|
||||
PB_COLLECTION = "wechat_private_users"
|
||||
|
||||
|
||||
def pkcs5_unpadding(data: bytes) -> bytes:
|
||||
pad = data[-1]
|
||||
if pad < 1 or pad > 32:
|
||||
return data
|
||||
return data[:-pad]
|
||||
|
||||
|
||||
def pkcs5_padding(data: bytes) -> bytes:
|
||||
amount_to_pad = 32 - (len(data) % 32)
|
||||
return data + bytes([amount_to_pad]) * amount_to_pad
|
||||
|
||||
|
||||
def get_aes_key_and_iv(encoding_aes_key: str) -> tuple[bytes, bytes]:
|
||||
aes_key = base64.b64decode(encoding_aes_key + "=")
|
||||
if len(aes_key) != 32:
|
||||
raise ValueError("encodingAESKey invalid")
|
||||
return aes_key, aes_key[:16]
|
||||
|
||||
|
||||
def aes_decrypt_base64(cipher_b64: str, encoding_aes_key: str) -> str:
|
||||
aes_key, iv = get_aes_key_and_iv(encoding_aes_key)
|
||||
compact = "".join(str(cipher_b64 or "").split())
|
||||
compact += "=" * (-len(compact) % 4)
|
||||
cipher_bytes = base64.b64decode(compact)
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||
decrypted = pkcs5_unpadding(cipher.decrypt(cipher_bytes))
|
||||
try:
|
||||
if len(decrypted) < 20:
|
||||
raise ValueError("payload too short")
|
||||
content = decrypted[16:]
|
||||
msg_len = struct.unpack("!I", content[:4])[0]
|
||||
msg_start = 4
|
||||
msg_end = msg_start + msg_len
|
||||
if msg_end > len(content):
|
||||
raise ValueError("msg length out of range")
|
||||
msg = content[msg_start:msg_end]
|
||||
text = msg.decode("utf-8")
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
pass
|
||||
return decrypted.decode("utf-8")
|
||||
|
||||
|
||||
def aes_encrypt_base64(plaintext: str, encoding_aes_key: str, app_id: str | None = None) -> str:
|
||||
aes_key, iv = get_aes_key_and_iv(encoding_aes_key)
|
||||
raw = plaintext.encode("utf-8")
|
||||
if app_id:
|
||||
data = os.urandom(16) + struct.pack("!I", len(raw)) + raw + app_id.encode("utf-8")
|
||||
else:
|
||||
data = raw
|
||||
padded = pkcs5_padding(data)
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||
return base64.b64encode(cipher.encrypt(padded)).decode("utf-8")
|
||||
|
||||
|
||||
def _build_keyword_h5_obj(query: str, user_id: str) -> Optional[dict]:
|
||||
q = (query or "").strip()
|
||||
if "电子书" in q:
|
||||
return {
|
||||
"news": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "数字游民",
|
||||
"description": "地理套利与自动化杠杆",
|
||||
"url": f"https://vip.hackrobot.cn/ebook?userid={user_id}",
|
||||
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
|
||||
"type": "h5",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
if "群" in q:
|
||||
return {
|
||||
"news": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "异度星球",
|
||||
"description": "电子书、视频教程、私密社群",
|
||||
"url": f"https://vip.hackrobot.cn?userid={user_id}",
|
||||
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
|
||||
"type": "h5",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _parse_decrypted_payload(decrypted_text: str) -> dict:
|
||||
text = (decrypted_text or "").strip()
|
||||
# 兼容 BOM / NUL / 控制字符前缀
|
||||
text = text.lstrip("\ufeff\x00\r\n\t ")
|
||||
if not text:
|
||||
raise ValueError("empty decrypted payload")
|
||||
|
||||
# 某些上游会把 JSON 再包一层字符串:"{\"UserId\":\"...\"}"
|
||||
if text.startswith('"') and text.endswith('"'):
|
||||
try:
|
||||
unwrapped = json.loads(text)
|
||||
if isinstance(unwrapped, str):
|
||||
text = unwrapped.strip().lstrip("\ufeff\x00\r\n\t ")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 若前面夹杂少量杂字符,尝试定位第一个结构起始位
|
||||
first_json = text.find("{")
|
||||
first_xml = text.find("<")
|
||||
starts = [x for x in [first_json, first_xml] if x >= 0]
|
||||
if starts:
|
||||
first = min(starts)
|
||||
if first > 0:
|
||||
text = text[first:]
|
||||
|
||||
if text.startswith("{"):
|
||||
obj = json.loads(text)
|
||||
obj["_payload_format"] = "json"
|
||||
return obj
|
||||
if text.startswith("<"):
|
||||
root = ET.fromstring(text)
|
||||
|
||||
def get_text(tag: str, default: str = "") -> str:
|
||||
node = root.find(tag)
|
||||
return (node.text or "").strip() if node is not None and node.text is not None else default
|
||||
|
||||
content_node = root.find("content")
|
||||
query = ""
|
||||
if content_node is not None:
|
||||
msg_node = content_node.find("msg")
|
||||
if msg_node is not None and msg_node.text:
|
||||
query = msg_node.text.strip()
|
||||
return {
|
||||
"UserId": get_text("userid", ""),
|
||||
"Query": query,
|
||||
"Timestamp": get_text("createtime", ""),
|
||||
"MsgId": get_text("msgid", ""),
|
||||
"ChannelId": get_text("appid", ""),
|
||||
"appid": get_text("appid", ""),
|
||||
"channel": get_text("channel", "0"),
|
||||
"from": get_text("from", "0"),
|
||||
"_payload_format": "xml",
|
||||
}
|
||||
# 兼容 querystring 明文:UserId=xxx&Query=你好&appid=...
|
||||
if "=" in text and "&" in text:
|
||||
qs = parse_qs(text, keep_blank_values=True)
|
||||
|
||||
def qv(*keys: str) -> str:
|
||||
for key in keys:
|
||||
vals = qs.get(key) or qs.get(key.lower()) or qs.get(key.upper()) or []
|
||||
if vals and str(vals[0]).strip():
|
||||
return str(vals[0]).strip()
|
||||
return ""
|
||||
|
||||
return {
|
||||
"UserId": qv("UserId", "userid", "userId", "from_user"),
|
||||
"Query": qv("Query", "query", "msg", "content"),
|
||||
"Timestamp": qv("Timestamp", "timestamp", "createTime", "msg_time"),
|
||||
"MsgId": qv("MsgId", "msgId", "msgid"),
|
||||
"ChannelId": qv("ChannelId", "appid", "appId"),
|
||||
"appid": qv("appid", "appId"),
|
||||
"channel": qv("channel"),
|
||||
"from": qv("from"),
|
||||
"_payload_format": "querystring",
|
||||
}
|
||||
raise ValueError("unsupported payload format")
|
||||
|
||||
|
||||
def _build_xml_callback_reply(data: dict, answer_text: str) -> dict:
|
||||
user_id = str(data.get("UserId", "")).strip()
|
||||
appid = str(data.get("ChannelId", "") or data.get("appid", "")).strip()
|
||||
channel = int(str(data.get("channel", "0") or "0"))
|
||||
msg = _build_keyword_h5_obj(str(data.get("Query", "")), user_id)
|
||||
content = msg if msg else {"msg": answer_text}
|
||||
return {"userid": user_id, "appid": appid, "content": content, "channel": channel, "from": 1}
|
||||
|
||||
|
||||
def _safe_filter_value(value: str) -> str:
|
||||
return (value or "").replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def _build_callback_dedupe_key(data: dict) -> str:
|
||||
return "|".join(
|
||||
[
|
||||
str(data.get("UserId", "")).strip(),
|
||||
str(data.get("MsgId", "")).strip(),
|
||||
str(data.get("Timestamp", "")).strip(),
|
||||
str(data.get("Query", "")).strip(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _is_duplicate_callback(data: dict, ttl_seconds: int = 3600) -> bool:
|
||||
now_ts = int(time.time())
|
||||
for k, exp in list(_processed_callback_cache.items()):
|
||||
if exp <= now_ts:
|
||||
_processed_callback_cache.pop(k, None)
|
||||
key = _build_callback_dedupe_key(data)
|
||||
if not key.strip("|"):
|
||||
return False
|
||||
if key in _processed_callback_cache:
|
||||
return True
|
||||
_processed_callback_cache[key] = now_ts + ttl_seconds
|
||||
return False
|
||||
|
||||
|
||||
async def query_private_messages(
|
||||
authtoken: str,
|
||||
wxbot_bid: str,
|
||||
page: int = 0,
|
||||
size: int = 30,
|
||||
filter_value: int = 0,
|
||||
request_id: str = "local-debug",
|
||||
) -> dict:
|
||||
url = "https://chatbot.weixin.qq.com/miniopenai/manualservice/getaccessstautuslist"
|
||||
body = {"bid": wxbot_bid, "filter": filter_value, "order": {"page": page, "size": size}, "base": {"requestid": request_id}}
|
||||
headers = {
|
||||
"authtoken": authtoken,
|
||||
"wxbot_bid": wxbot_bid,
|
||||
"content-type": "application/json",
|
||||
"charset": "utf-8",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10, verify=False) as client:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _pb_upsert_user_profile(record: dict) -> None:
|
||||
pb_url = (PB_URL or "").rstrip("/")
|
||||
if not pb_url or not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
|
||||
return
|
||||
userid = str(record.get("userid", "")).strip()
|
||||
if not userid:
|
||||
return
|
||||
async with httpx.AsyncClient(timeout=10, verify=False) as client:
|
||||
auth_resp = await client.post(
|
||||
f"{pb_url}/api/admins/auth-with-password",
|
||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||
)
|
||||
if auth_resp.status_code == 404:
|
||||
auth_resp = await client.post(
|
||||
f"{pb_url}/api/collections/_superusers/auth-with-password",
|
||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||
)
|
||||
if auth_resp.status_code != 200:
|
||||
return
|
||||
token = (auth_resp.json() or {}).get("token", "")
|
||||
if not token:
|
||||
return
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
user_expr = _safe_filter_value(userid)
|
||||
# 业务要求:wechat_private_users.userid 全局唯一(不按 channel_id 分桶)
|
||||
filter_exp = f'userid="{user_expr}"'
|
||||
list_resp = await client.get(
|
||||
f"{pb_url}/api/collections/{PB_COLLECTION}/records",
|
||||
params={"page": 1, "perPage": 200, "filter": filter_exp, "sort": "-updated"},
|
||||
headers=headers,
|
||||
)
|
||||
items = ((list_resp.json() or {}).get("items")) or [] if list_resp.status_code == 200 else []
|
||||
if items:
|
||||
rid = items[0].get("id")
|
||||
if rid:
|
||||
await client.patch(f"{pb_url}/api/collections/{PB_COLLECTION}/records/{rid}", json=record, headers=headers)
|
||||
# 清理历史重复数据,确保 userid 唯一
|
||||
for extra in items[1:]:
|
||||
extra_id = str(extra.get("id", "")).strip()
|
||||
if not extra_id:
|
||||
continue
|
||||
await client.delete(
|
||||
f"{pb_url}/api/collections/{PB_COLLECTION}/records/{extra_id}",
|
||||
headers=headers,
|
||||
)
|
||||
return
|
||||
await client.post(f"{pb_url}/api/collections/{PB_COLLECTION}/records", json=record, headers=headers)
|
||||
|
||||
|
||||
async def _sync_user_profile_from_b(data: dict) -> None:
|
||||
authtoken = (PM_SYNC_AUTHTOKEN or "").strip()
|
||||
wxbot_bid = (PM_SYNC_DEFAULT_WXBOT_BID or "").strip()
|
||||
if not authtoken or not wxbot_bid:
|
||||
return
|
||||
target_userid = str(data.get("UserId", "")).strip()
|
||||
if not target_userid or _is_duplicate_callback(data):
|
||||
return
|
||||
result = await query_private_messages(
|
||||
authtoken=authtoken,
|
||||
wxbot_bid=wxbot_bid,
|
||||
page=0,
|
||||
size=100,
|
||||
filter_value=0,
|
||||
request_id=f"thirdapi-{target_userid}",
|
||||
)
|
||||
contacts = result.get("contacts") or []
|
||||
target = next((c for c in contacts if str(c.get("userId", "") or c.get("userid", "")).strip() == target_userid), None)
|
||||
if not target:
|
||||
return
|
||||
last_msg = target.get("lastMsg") or {}
|
||||
await _pb_upsert_user_profile(
|
||||
{
|
||||
"userid": target_userid,
|
||||
"channel_id": str(data.get("ChannelId", "")).strip(),
|
||||
"nick": target.get("nick", ""),
|
||||
"avatar": target.get("headImg") or target.get("avatar") or target.get("headimgurl") or target.get("headImgUrl") or "",
|
||||
"source_query": str(data.get("Query", "")).strip(),
|
||||
"source_message_id": str(data.get("MsgId", "")).strip(),
|
||||
"source_message_time": str(data.get("Timestamp", "")),
|
||||
"matched_last_msg": last_msg.get("content", ""),
|
||||
"matched_last_msg_id": str(last_msg.get("msgId", "") or last_msg.get("id", "")),
|
||||
"matched_last_msg_time": str(target.get("lastActiveTime", "")),
|
||||
"raw_contact": json.dumps(target, ensure_ascii=False),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _safe_sync_user_profile_from_b(data: dict) -> None:
|
||||
try:
|
||||
await _sync_user_profile_from_b(data)
|
||||
except Exception:
|
||||
logger.error("chatbot后台同步失败(已忽略)")
|
||||
|
||||
|
||||
async def handle_business(data: dict) -> str:
|
||||
user_id = str(data.get("UserId", "")).strip()
|
||||
query = str(data.get("Query", "")).strip()
|
||||
msg = _build_keyword_h5_obj(query, user_id)
|
||||
if msg:
|
||||
return json.dumps(msg, ensure_ascii=False)
|
||||
return ""
|
||||
|
||||
|
||||
async def _handle_wechat_request(request: Request, app_id: Optional[str], background_tasks: BackgroundTasks) -> str:
|
||||
if app_id and app_id != APP_ID:
|
||||
logger.warning("chatbot app_id mismatch expected=%s actual=%s", APP_ID, app_id)
|
||||
raw_body = (await request.body()).decode("utf-8", errors="ignore").strip()
|
||||
if not raw_body:
|
||||
raise HTTPException(status_code=400, detail="empty body")
|
||||
|
||||
cipher_b64 = raw_body
|
||||
# 兼容三种请求体:
|
||||
# 1) 纯密文 base64
|
||||
# 2) JSON: {"encrypted":"..."} / {"encrypt":"..."} / {"Encrypt":"..."}
|
||||
# 3) x-www-form-urlencoded: encrypted=... / encrypt=...
|
||||
try:
|
||||
outer = json.loads(raw_body)
|
||||
if isinstance(outer, dict):
|
||||
for key in ("encrypted", "encrypt", "Encrypt"):
|
||||
val = outer.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
cipher_b64 = val
|
||||
break
|
||||
except Exception:
|
||||
form = parse_qs(raw_body, keep_blank_values=True)
|
||||
for key in ("encrypted", "encrypt", "Encrypt"):
|
||||
vals = form.get(key) or []
|
||||
if vals and str(vals[0]).strip():
|
||||
cipher_b64 = str(vals[0])
|
||||
break
|
||||
|
||||
cipher_b64 = "".join(str(cipher_b64).split()).strip()
|
||||
try:
|
||||
decrypted = aes_decrypt_base64(cipher_b64, ENCODING_AES_KEY)
|
||||
logger.info(
|
||||
"chatbot decrypted preview app_id=%s preview=%s",
|
||||
app_id or "",
|
||||
(decrypted or "")[:200],
|
||||
)
|
||||
data = _parse_decrypted_payload(decrypted)
|
||||
logger.info(
|
||||
"chatbot payload parsed app_id=%s format=%s user=%s query=%s",
|
||||
app_id or "",
|
||||
data.get("_payload_format", ""),
|
||||
str(data.get("UserId", "") or data.get("userId", ""))[:80],
|
||||
str(data.get("Query", ""))[:80],
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"chatbot decrypt failed app_id=%s content_type=%s body_prefix=%s err=%s",
|
||||
app_id or "",
|
||||
request.headers.get("content-type", ""),
|
||||
raw_body[:160],
|
||||
error,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="decrypt error")
|
||||
|
||||
answer_text = await handle_business(data)
|
||||
background_tasks.add_task(_safe_sync_user_profile_from_b, data)
|
||||
|
||||
if str(data.get("_payload_format", "")) == "xml":
|
||||
resp_plain = json.dumps(_build_xml_callback_reply(data, answer_text), ensure_ascii=False)
|
||||
response_app_id = str(data.get("ChannelId", "") or data.get("appid", "")).strip() or APP_ID
|
||||
else:
|
||||
resp_plain = json.dumps({"answer_type": "text", "text_info": {"short_answer": answer_text}}, ensure_ascii=False)
|
||||
# 与原始 chatbot 服务保持一致:thirdapi(URL 带 app_id)回包使用纯 AES,不拼 app_id
|
||||
response_app_id = None if app_id else APP_ID
|
||||
|
||||
return aes_encrypt_base64(resp_plain, ENCODING_AES_KEY, response_app_id)
|
||||
|
||||
|
||||
@router.post("/", response_class=PlainTextResponse)
|
||||
async def chatbot_root(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
|
||||
return await _handle_wechat_request(request, app_id, background_tasks)
|
||||
|
||||
|
||||
@router.post("/wechat/thirdapi", response_class=PlainTextResponse)
|
||||
async def chatbot_thirdapi(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
|
||||
return await _handle_wechat_request(request, app_id, background_tasks)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def chatbot_health():
|
||||
return {"status": "ok", "module": "chatbot-clone", "profile": ACTIVE_PROFILE}
|
||||
|
||||
|
||||
@router.post("/api/private-message/query")
|
||||
async def chatbot_private_message_query(payload: dict):
|
||||
authtoken = str(payload.get("authtoken", "")).strip()
|
||||
wxbot_bid = str(payload.get("wxbot_bid", "")).strip()
|
||||
if not authtoken or not wxbot_bid:
|
||||
raise HTTPException(status_code=400, detail="authtoken 和 wxbot_bid 不能为空")
|
||||
result = await query_private_messages(
|
||||
authtoken=authtoken,
|
||||
wxbot_bid=wxbot_bid,
|
||||
page=int(payload.get("page", 0)),
|
||||
size=int(payload.get("size", 30)),
|
||||
filter_value=int(payload.get("filter", 0)),
|
||||
request_id=str(payload.get("requestid", "local-debug")).strip() or "local-debug",
|
||||
)
|
||||
contacts = result.get("contacts") or []
|
||||
return {
|
||||
"ok": True,
|
||||
"count": result.get("count"),
|
||||
"users": [
|
||||
{
|
||||
"nick": c.get("nick"),
|
||||
"userId": c.get("userId") or c.get("userid"),
|
||||
"avatar": c.get("avatar") or c.get("headImg") or c.get("headimgurl") or c.get("headImgUrl"),
|
||||
"lastMsg": (c.get("lastMsg") or {}).get("content"),
|
||||
"lastActiveTime": c.get("lastActiveTime"),
|
||||
}
|
||||
for c in contacts
|
||||
],
|
||||
"raw": result,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/private-message/analyze")
|
||||
async def chatbot_private_message_analyze(payload: dict):
|
||||
authtoken = str(payload.get("authtoken", "")).strip()
|
||||
if not authtoken:
|
||||
raise HTTPException(status_code=400, detail="authtoken 不能为空")
|
||||
try:
|
||||
parts = authtoken.split(".")
|
||||
payload_part = parts[1] if len(parts) == 3 else ""
|
||||
payload_part += "=" * (-len(payload_part) % 4)
|
||||
claims = json.loads(base64.urlsafe_b64decode(payload_part.encode("utf-8")).decode("utf-8")) if payload_part else {}
|
||||
except Exception:
|
||||
claims = {}
|
||||
now_ts = int(datetime.now(timezone.utc).timestamp())
|
||||
exp = int(claims.get("exp", 0)) if str(claims.get("exp", "")).isdigit() else 0
|
||||
expires_in_seconds = exp - now_ts if exp else None
|
||||
status = "无法解析过期时间"
|
||||
if isinstance(expires_in_seconds, int):
|
||||
if expires_in_seconds <= 0:
|
||||
status = "已过期"
|
||||
elif expires_in_seconds < 3600:
|
||||
status = "即将过期(1小时内)"
|
||||
else:
|
||||
status = "有效"
|
||||
return {
|
||||
"status": status,
|
||||
"now_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"claims": claims,
|
||||
"analysis": {
|
||||
"expires_in_seconds": expires_in_seconds,
|
||||
"expires_in_hours": round(expires_in_seconds / 3600, 2) if isinstance(expires_in_seconds, int) else None,
|
||||
"summary": "该分析基于 JWT payload 解码,不包含签名有效性校验。",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/private-message", response_class=HTMLResponse)
|
||||
def chatbot_private_message_page():
|
||||
return """
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Chatbot 私信调试台</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, "Microsoft YaHei", sans-serif; margin: 0; padding: 20px; background: #0f172a; color: #e2e8f0; }
|
||||
.wrap { max-width: 980px; margin: 0 auto; display: grid; gap: 12px; }
|
||||
.card { background: #111827; border: 1px solid #334155; border-radius: 10px; padding: 14px; }
|
||||
h2 { margin: 0 0 8px; }
|
||||
label { display:block; margin: 8px 0 6px; font-size: 13px; color: #cbd5e1; }
|
||||
input, textarea { width: 100%; border-radius: 8px; border: 1px solid #475569; background: #0b1220; color: #e2e8f0; padding: 10px; box-sizing: border-box; }
|
||||
textarea { min-height: 88px; }
|
||||
.row { display:grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.btns { display:flex; gap:10px; margin-top: 10px; }
|
||||
button { border: 0; background: #2563eb; color: white; border-radius: 8px; padding: 10px 14px; cursor: pointer; }
|
||||
button.alt { background: #4f46e5; }
|
||||
#status { font-size: 13px; color: #93c5fd; }
|
||||
#users { display:grid; grid-template-columns: repeat(auto-fill,minmax(250px,1fr)); gap: 8px; }
|
||||
.u { border:1px solid #334155; border-radius:8px; padding:8px; background:#0b1220; }
|
||||
.uid { color:#93c5fd; font-size:12px; word-break:break-all; }
|
||||
pre { background:#020617; border:1px solid #334155; border-radius:8px; padding:10px; overflow:auto; max-height:420px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="card">
|
||||
<h2>Chatbot 私信调试台</h2>
|
||||
<div id="status">准备就绪</div>
|
||||
<label>默认配置</label>
|
||||
<select id="preset" style="width:100%;border-radius:8px;border:1px solid #475569;background:#0b1220;color:#e2e8f0;padding:10px;box-sizing:border-box;">
|
||||
<option value="eric">Eric在旅行</option>
|
||||
<option value="xidu">异度世界</option>
|
||||
</select>
|
||||
<label>authtoken</label>
|
||||
<textarea id="authtoken" placeholder="粘贴抓包 authtoken"></textarea>
|
||||
<label>wxbot_bid</label>
|
||||
<input id="wxbot_bid" placeholder="例如: 75021053883a9090c67d5becde7f4b0a" />
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>page</label>
|
||||
<input id="page" value="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label>size</label>
|
||||
<input id="size" value="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<button onclick="queryPm()">查询私信</button>
|
||||
<button class="alt" onclick="analyzeToken()">分析 Token</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3 style="margin:0 0 8px;">用户列表</h3>
|
||||
<div id="users"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3 style="margin:0 0 8px;">原始结果</h3>
|
||||
<pre id="out">{}</pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const presets = {
|
||||
eric: {
|
||||
authtoken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4",
|
||||
wxbot_bid: "75021053883a9090c67d5becde7f4b0a",
|
||||
},
|
||||
xidu: {
|
||||
authtoken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4",
|
||||
wxbot_bid: "4888c6ab5cb70dcc3adea1e2d2ff0201",
|
||||
}
|
||||
};
|
||||
function setStatus(s){ $("status").textContent = s; }
|
||||
function renderJson(v){ $("out").textContent = JSON.stringify(v, null, 2); }
|
||||
function renderUsers(users){
|
||||
const box = $("users");
|
||||
if(!Array.isArray(users) || !users.length){
|
||||
box.innerHTML = '<div class="u">暂无数据</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = users.map(u => {
|
||||
const nick = (u.nick || "未命名").replace(/</g,"<").replace(/>/g,">");
|
||||
const uid = (u.userId || "").replace(/</g,"<").replace(/>/g,">");
|
||||
const last = (u.lastMsg || "").replace(/</g,"<").replace(/>/g,">");
|
||||
return `<div class="u"><div>${nick}</div><div class="uid">${uid}</div><div style="font-size:12px;color:#94a3b8;margin-top:4px;">${last}</div></div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function applyPreset(key){
|
||||
const p = presets[key];
|
||||
if(!p) return;
|
||||
$("authtoken").value = p.authtoken;
|
||||
$("wxbot_bid").value = p.wxbot_bid;
|
||||
}
|
||||
|
||||
async function queryPm(){
|
||||
try{
|
||||
setStatus("查询中...");
|
||||
const payload = {
|
||||
authtoken: $("authtoken").value.trim(),
|
||||
wxbot_bid: $("wxbot_bid").value.trim(),
|
||||
page: Number($("page").value || 0),
|
||||
size: Number($("size").value || 30),
|
||||
};
|
||||
const res = await fetch("/chatbot/api/private-message/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setStatus(res.ok ? "查询成功" : "查询失败");
|
||||
renderUsers(data.users || []);
|
||||
renderJson(data);
|
||||
}catch(e){
|
||||
setStatus("请求异常");
|
||||
renderJson({ error: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeToken(){
|
||||
try{
|
||||
setStatus("分析中...");
|
||||
const payload = { authtoken: $("authtoken").value.trim() };
|
||||
const res = await fetch("/chatbot/api/private-message/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setStatus(res.ok ? "分析成功" : "分析失败");
|
||||
renderJson(data);
|
||||
}catch(e){
|
||||
setStatus("请求异常");
|
||||
renderJson({ error: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
$("preset").addEventListener("change", (e) => applyPreset(e.target.value));
|
||||
applyPreset("eric");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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))
|
||||
@@ -193,7 +193,6 @@ async def ensure_user(item: EnsureUserRequest):
|
||||
"email": email,
|
||||
"password": DEFAULT_PASSWORD,
|
||||
"passwordConfirm": DEFAULT_PASSWORD,
|
||||
"live_allowed": True,
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
"""salon 项目接口:克隆 cnomadcna 的支付与 meetup 流程,使用 site_id=salon"""
|
||||
from ._digital_base import create_digital_router
|
||||
|
||||
router = create_digital_router(prefix="/salon", project_name="salon", site_id="salon")
|
||||
@@ -1,502 +0,0 @@
|
||||
"""
|
||||
salon 加入流程:check-user、ensure-user、complete-order
|
||||
克隆 meetup 逻辑,使用 site_id=salon
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
||||
|
||||
SITE_ID = "salon"
|
||||
router = APIRouter(prefix="/api/salon", tags=["salon"])
|
||||
DEFAULT_PASSWORD = "12345678"
|
||||
|
||||
_SALON_ADMIN_TOKEN_CACHE: tuple[str, float] | None = None
|
||||
_TOKEN_CACHE_TTL = 300
|
||||
|
||||
|
||||
def _get_admin_token() -> tuple[str | None, str | None]:
|
||||
global _SALON_ADMIN_TOKEN_CACHE
|
||||
now = time.time()
|
||||
if _SALON_ADMIN_TOKEN_CACHE and _SALON_ADMIN_TOKEN_CACHE[1] > now:
|
||||
return _SALON_ADMIN_TOKEN_CACHE[0], None
|
||||
|
||||
if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
|
||||
return None, "PB_ADMIN_EMAIL 或 PB_ADMIN_PASSWORD 未配置"
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
for path in ["/api/collections/_superusers/auth-with-password", "/api/admins/auth-with-password"]:
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{base}{path}",
|
||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||
timeout=4,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
token = data.get("token")
|
||||
if token:
|
||||
_SALON_ADMIN_TOKEN_CACHE = (token, now + _TOKEN_CACHE_TTL)
|
||||
return token, None
|
||||
if r.status_code == 404:
|
||||
continue
|
||||
try:
|
||||
msg = r.json().get("message", r.text[:100])
|
||||
except Exception:
|
||||
msg = r.text[:100]
|
||||
logging.warning(f"PocketBase admin auth failed: status={r.status_code}, msg={msg}")
|
||||
return None, f"PocketBase 管理员认证失败: {msg}"
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
logging.error(f"PocketBase 连接失败: {e}")
|
||||
return None, f"无法连接 {PB_URL},请检查网络或 PB_URL"
|
||||
except Exception as e:
|
||||
logging.warning(f"PocketBase auth try {path}: {e}")
|
||||
return None, "PocketBase 管理员认证失败: 请确认 PB_URL 及管理员账号密码正确"
|
||||
|
||||
|
||||
class CheckUserRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class EnsureUserRequest(BaseModel):
|
||||
email: str
|
||||
password: str | None = None
|
||||
|
||||
|
||||
def _check_site_vip(user_id: str, token: str | None = None) -> bool:
|
||||
if not token:
|
||||
token, _ = _get_admin_token()
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{PB_URL}/api/collections/site_vip/records",
|
||||
params={
|
||||
"filter": f'user_id = "{user_id}" && site_id = "{SITE_ID}"',
|
||||
"perPage": 1,
|
||||
"sort": "-expires_at",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=4,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return False
|
||||
data = r.json()
|
||||
items = data.get("items") or []
|
||||
if not items:
|
||||
return False
|
||||
rec = items[0]
|
||||
exp = rec.get("expires_at")
|
||||
if not exp:
|
||||
return True
|
||||
try:
|
||||
from datetime import datetime as _dt, timezone
|
||||
exp_dt = _dt.fromisoformat(exp.replace("Z", "+00:00"))
|
||||
now = _dt.now(timezone.utc)
|
||||
if exp_dt.tzinfo is None:
|
||||
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
|
||||
return exp_dt > now
|
||||
except Exception:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/check-user")
|
||||
async def check_user(item: CheckUserRequest):
|
||||
email = (item.email or "").strip()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="请输入邮箱")
|
||||
|
||||
token, err = _get_admin_token()
|
||||
if not token:
|
||||
raise HTTPException(status_code=500, detail=f"服务配置错误:{err}")
|
||||
|
||||
try:
|
||||
filter_val = f'email="{email}"'
|
||||
r = requests.get(
|
||||
f"{PB_URL}/api/collections/users/records",
|
||||
params={"filter": filter_val, "perPage": 1},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=4,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise HTTPException(status_code=500, detail="查询失败")
|
||||
data = r.json()
|
||||
items = data.get("items") or []
|
||||
exists = len(items) > 0
|
||||
user_id = items[0].get("id") if items else None
|
||||
vip = _check_site_vip(user_id, token) if user_id else False
|
||||
return {"ok": True, "exists": exists, "vip": vip, "user_id": user_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"salon check-user error: {e}")
|
||||
raise HTTPException(status_code=500, detail="操作失败")
|
||||
|
||||
|
||||
@router.post("/ensure-user")
|
||||
async def ensure_user(item: EnsureUserRequest):
|
||||
email = (item.email or "").strip()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="请输入邮箱")
|
||||
|
||||
password = (item.password or "").strip() or DEFAULT_PASSWORD
|
||||
|
||||
try:
|
||||
login_r = requests.post(
|
||||
f"{PB_URL}/api/collections/users/auth-with-password",
|
||||
json={"identity": email, "password": password},
|
||||
timeout=10,
|
||||
)
|
||||
if login_r.status_code == 200:
|
||||
data = login_r.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"user_id": data.get("record", {}).get("id"),
|
||||
"token": data.get("token"),
|
||||
"record": data.get("record"),
|
||||
"is_new": False,
|
||||
}
|
||||
|
||||
try:
|
||||
err_data = login_r.json()
|
||||
except Exception:
|
||||
err_data = {}
|
||||
is_not_found = login_r.status_code == 400 and (
|
||||
"Invalid" in str(err_data.get("message", ""))
|
||||
or "identity" in str(err_data.get("message", "")).lower()
|
||||
)
|
||||
if not is_not_found and item.password:
|
||||
raise HTTPException(status_code=400, detail="密码错误")
|
||||
|
||||
token, err = _get_admin_token()
|
||||
if not token:
|
||||
raise HTTPException(status_code=500, detail=f"服务配置错误:{err}")
|
||||
|
||||
create_r = requests.post(
|
||||
f"{PB_URL}/api/collections/users/records",
|
||||
json={
|
||||
"email": email,
|
||||
"password": DEFAULT_PASSWORD,
|
||||
"passwordConfirm": DEFAULT_PASSWORD,
|
||||
"live_allowed": True,
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if create_r.status_code != 200:
|
||||
try:
|
||||
create_err = create_r.json()
|
||||
except Exception:
|
||||
create_err = {}
|
||||
data = create_err.get("data", {})
|
||||
if (
|
||||
create_r.status_code == 400
|
||||
and "already" in str(data.get("email", {}).get("message", "")).lower()
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="该邮箱已注册,请输入密码登录")
|
||||
msg = create_err.get("message", "创建账号失败")
|
||||
logging.warning(f"salon ensure-user create failed: status={create_r.status_code}, msg={msg}")
|
||||
raise HTTPException(status_code=500, detail=msg)
|
||||
|
||||
final_r = requests.post(
|
||||
f"{PB_URL}/api/collections/users/auth-with-password",
|
||||
json={"identity": email, "password": DEFAULT_PASSWORD},
|
||||
timeout=10,
|
||||
)
|
||||
if final_r.status_code != 200:
|
||||
raise HTTPException(status_code=500, detail="登录失败")
|
||||
|
||||
auth_data = final_r.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"user_id": auth_data.get("record", {}).get("id"),
|
||||
"token": auth_data.get("token"),
|
||||
"record": auth_data.get("record"),
|
||||
"is_new": True,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"salon ensure-user error: {e}")
|
||||
raise HTTPException(status_code=500, detail="操作失败")
|
||||
|
||||
|
||||
def _decode_pending_email(user_id: str) -> str | None:
|
||||
if not user_id.startswith("pending_"):
|
||||
return None
|
||||
try:
|
||||
return bytes.fromhex(user_id[8:]).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _query_zpay_paid(order_id: str) -> bool:
|
||||
try:
|
||||
from ..payment import get_payment_provider
|
||||
from ..payment.zpay import ZPayProvider
|
||||
provider = get_payment_provider()
|
||||
if not isinstance(provider, ZPayProvider):
|
||||
return False
|
||||
result = provider.query_order_status(order_id, timeout=10)
|
||||
return bool(result.get("paid"))
|
||||
except Exception as e:
|
||||
logging.warning(f"salon complete-order zpay query failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/complete-order")
|
||||
async def complete_order(item: dict):
|
||||
order_id = (item.get("order_id") or "").strip()
|
||||
if not order_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 order_id")
|
||||
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
token, err = _get_admin_token()
|
||||
if not token:
|
||||
raise HTTPException(status_code=500, detail=f"服务配置错误:{err}")
|
||||
|
||||
from ..services.payment import parse_order_id
|
||||
|
||||
user_id, pay_type = parse_order_id(order_id)
|
||||
if not user_id or pay_type != "salon":
|
||||
raise HTTPException(status_code=400, detail="订单格式无效")
|
||||
|
||||
max_retries = 4
|
||||
for attempt in range(max_retries):
|
||||
r = requests.get(
|
||||
f"{base}/api/collections/site_vip/records",
|
||||
params={"filter": f'order_id = "{order_id}" && site_id = "{SITE_ID}"', "perPage": 1},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
total = (r.json().get("totalItems") or 0) if r.status_code == 200 else 0
|
||||
if total > 0:
|
||||
break
|
||||
if attempt < max_retries - 1:
|
||||
paid_via = None
|
||||
pay_price = ""
|
||||
if _query_zpay_paid(order_id):
|
||||
paid_via = "zpay"
|
||||
try:
|
||||
from ..payment.zpay import ZPayProvider
|
||||
from ..payment import get_payment_provider
|
||||
p = get_payment_provider()
|
||||
if isinstance(p, ZPayProvider):
|
||||
r = p.query_order_status(order_id, timeout=8)
|
||||
if r.get("paid"):
|
||||
pay_price = str(r.get("pay_price", "") or r.get("money", ""))
|
||||
except Exception:
|
||||
pass
|
||||
if not paid_via:
|
||||
try:
|
||||
from ..payment.xorpay import XorPayProvider
|
||||
from ..payment import get_payment_provider
|
||||
p = get_payment_provider()
|
||||
if isinstance(p, XorPayProvider):
|
||||
r = p.query_order_status(order_id, timeout=8)
|
||||
if r.get("paid"):
|
||||
paid_via = "xorpay"
|
||||
pay_price = str(r.get("pay_price", ""))
|
||||
except Exception:
|
||||
pass
|
||||
if paid_via:
|
||||
from ..services.payment import handle_payment_success
|
||||
# pay_price 为空时用默认茶歇费 1 元
|
||||
price = pay_price if pay_price and float(pay_price or 0) > 0 else "1"
|
||||
try:
|
||||
handle_payment_success(
|
||||
order_id,
|
||||
price,
|
||||
f"salon-{paid_via}-poll",
|
||||
use_memos=False,
|
||||
site_id=SITE_ID,
|
||||
)
|
||||
time.sleep(1)
|
||||
continue
|
||||
except Exception as e:
|
||||
logging.warning(f"salon complete-order 手动触发支付处理失败: {e}")
|
||||
if paid_via:
|
||||
time.sleep(2)
|
||||
continue
|
||||
resp_preview = r.text[:200] if r.ok else f"status={r.status_code}"
|
||||
logging.warning(f"salon complete-order 未找到订单: order_id={order_id}, attempt={attempt + 1}, pb_resp={resp_preview}")
|
||||
raise HTTPException(status_code=400, detail="订单不存在或未支付成功")
|
||||
|
||||
if user_id.startswith("pending_"):
|
||||
email = _decode_pending_email(user_id)
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="订单格式无效")
|
||||
login_r = requests.post(
|
||||
f"{base}/api/collections/users/auth-with-password",
|
||||
json={"identity": email, "password": DEFAULT_PASSWORD},
|
||||
timeout=10,
|
||||
)
|
||||
if login_r.status_code != 200:
|
||||
raise HTTPException(status_code=500, detail="登录失败,请稍后重试")
|
||||
auth_data = login_r.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"token": auth_data.get("token"),
|
||||
"record": auth_data.get("record"),
|
||||
"is_new": True,
|
||||
}
|
||||
|
||||
return {"ok": True, "token": None, "record": None, "is_new": False}
|
||||
|
||||
|
||||
@router.get("/vip/check")
|
||||
async def vip_check(user_id: str = ""):
|
||||
"""检查用户是否为 salon VIP"""
|
||||
if not user_id:
|
||||
return {"vip": False}
|
||||
token, _ = _get_admin_token()
|
||||
return {"vip": _check_site_vip(user_id, token)}
|
||||
|
||||
|
||||
@router.get("/join/by-wechat")
|
||||
async def join_by_wechat(wechat_id: str = ""):
|
||||
"""按微信号查询 solanRed 记录,供 salon 前端提交后刷新状态"""
|
||||
if not wechat_id or not wechat_id.strip():
|
||||
return {"hasRecord": False, "record": None, "isComplete": False, "isWechatFriend": False, "isApproved": False, "isRejected": False, "vip": False}
|
||||
try:
|
||||
from ..services import get_pb_client
|
||||
pb_client = get_pb_client()
|
||||
solan = pb_client.collection("solanRed")
|
||||
safe_wechat = wechat_id.strip().replace("\\", "\\\\").replace('"', '\\"')
|
||||
existing = solan.get_list(1, 1, {"filter": f'wechatId = "{safe_wechat}"', "sort": "-created"})
|
||||
if not existing.items:
|
||||
return {"hasRecord": False, "record": None, "isComplete": False, "isWechatFriend": False, "isApproved": False, "isRejected": False, "vip": False}
|
||||
rec = existing.items[0]
|
||||
amount = rec.get("amount") or 0
|
||||
is_complete = bool(rec.get("order_id")) and (amount or 0) > 0
|
||||
return {
|
||||
"hasRecord": True,
|
||||
"record": rec,
|
||||
"isComplete": is_complete,
|
||||
"isWechatFriend": bool(rec.get("is_wechat_friend")),
|
||||
"isApproved": bool(rec.get("is_approved")),
|
||||
"isRejected": bool(rec.get("is_rejected")),
|
||||
"vip": bool(rec.get("vip")),
|
||||
}
|
||||
except Exception as e:
|
||||
logging.warning("salon join/by-wechat error: %s", e)
|
||||
return {"hasRecord": False, "record": None, "isComplete": False, "isWechatFriend": False, "isApproved": False, "isRejected": False, "vip": False}
|
||||
|
||||
|
||||
@router.get("/join/status")
|
||||
async def join_status(user_id: str = ""):
|
||||
"""检查用户是否已提交 salon 加入申请"""
|
||||
if not user_id:
|
||||
return {"hasRecord": False, "isComplete": False}
|
||||
try:
|
||||
from ..services import get_pb_client
|
||||
pb_client = get_pb_client()
|
||||
solan = pb_client.collection("solanRed")
|
||||
existing = solan.get_list(1, 1, {"filter": f'user_id = "{user_id}"', "sort": "-created"})
|
||||
has_record = len(existing.items) > 0
|
||||
rec = existing.items[0] if existing.items else None
|
||||
is_complete = bool(rec and rec.get("order_id") and rec.get("amount", 0) > 0)
|
||||
return {"hasRecord": has_record, "isComplete": is_complete}
|
||||
except Exception:
|
||||
return {"hasRecord": False, "isComplete": False}
|
||||
|
||||
|
||||
def _validate_xiaohongshu_url(url: str) -> dict | None:
|
||||
"""校验小红书主页 URL 并尝试爬取用户名/头像/粉丝数/性别。返回 None 表示校验失败。"""
|
||||
url = (url or "").strip()
|
||||
if not url:
|
||||
return None
|
||||
if "xiaohongshu.com" not in url.lower():
|
||||
return None
|
||||
if not url.startswith("http://") and not url.startswith("https://"):
|
||||
return None
|
||||
try:
|
||||
r = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"})
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
html = r.text
|
||||
nickname = ""
|
||||
avatar = ""
|
||||
followers = 0
|
||||
gender = ""
|
||||
import re
|
||||
if '"nickname"' in html or '"user"' in html:
|
||||
m = re.search(r'"nickname"\s*:\s*"([^"]+)"', html)
|
||||
if m:
|
||||
nickname = m.group(1)
|
||||
m = re.search(r'"avatar"\s*:\s*"([^"]+)"', html)
|
||||
if m:
|
||||
avatar = m.group(1).replace("\\u002F", "/")
|
||||
m = re.search(r'"follows"\s*:\s*(\d+)', html)
|
||||
if m:
|
||||
followers = int(m.group(1))
|
||||
if not nickname and '"desc"' in html:
|
||||
m = re.search(r'"desc"\s*:\s*"([^"]+)"', html)
|
||||
if m:
|
||||
nickname = m.group(1)[:20]
|
||||
return {"nickname": nickname or "用户", "avatar": avatar, "followers": followers, "gender": gender, "url": url}
|
||||
except Exception as e:
|
||||
logging.warning(f"xiaohongshu crawl failed: {e}")
|
||||
return {"nickname": "用户", "avatar": "", "followers": 0, "gender": "", "url": url}
|
||||
|
||||
|
||||
@router.post("/join")
|
||||
async def submit_join(item: dict):
|
||||
"""salon 加入社区报名表单,写入 solanRed 集合。需提供 xiaohongshu_url,提交前校验并爬取信息。"""
|
||||
user_id = item.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="missing user_id")
|
||||
|
||||
xiaohongshu_url = (item.get("xiaohongshu_url") or "").strip()
|
||||
if not xiaohongshu_url:
|
||||
raise HTTPException(status_code=400, detail="请填写小红书主页地址")
|
||||
|
||||
profile = _validate_xiaohongshu_url(xiaohongshu_url)
|
||||
if not profile:
|
||||
raise HTTPException(status_code=400, detail="小红书主页地址无效或无法访问,请检查后重试")
|
||||
|
||||
try:
|
||||
from ..services import get_pb_client
|
||||
pb_client = get_pb_client()
|
||||
solan = pb_client.collection("solanRed")
|
||||
wechat_id = (item.get("wechatId") or "").strip()
|
||||
if wechat_id:
|
||||
safe_wechat = wechat_id.replace("\\", "\\\\").replace('"', '\\"')
|
||||
existing = solan.get_list(1, 1, {"filter": f'wechatId = "{safe_wechat}"'})
|
||||
if existing.items:
|
||||
raise HTTPException(status_code=400, detail="该微信号已报名,请勿重复提交")
|
||||
pb_data = {
|
||||
"user_id": user_id,
|
||||
"nickname": item.get("nickname", ""),
|
||||
"occupation": item.get("occupation", ""),
|
||||
"reason": item.get("reason", ""),
|
||||
"wechatId": item.get("wechatId", ""),
|
||||
"gender": profile.get("gender", "") or item.get("gender", ""),
|
||||
"education": item.get("education", ""),
|
||||
"gradYear": item.get("gradYear", ""),
|
||||
"phone": item.get("phone", ""),
|
||||
"single": item.get("single", ""),
|
||||
"media": item.get("media", ""),
|
||||
"calculated_age": item.get("calculated_age", 0),
|
||||
"type": item.get("type", ""),
|
||||
"order_id": "",
|
||||
"amount": 0,
|
||||
"xiaohongshu_url": xiaohongshu_url,
|
||||
"xiaohongshu_nickname": profile.get("nickname", ""),
|
||||
"xiaohongshu_avatar": profile.get("avatar", ""),
|
||||
"xiaohongshu_followers": profile.get("followers", 0),
|
||||
}
|
||||
record = solan.create(pb_data)
|
||||
logging.info(f"salon 申请表单提交成功 - user_id: {user_id}, record_id: {record.id}")
|
||||
return {"ok": True, "user_id": user_id, "record_id": record.id}
|
||||
except Exception as e:
|
||||
logging.error(f"salon join 提交失败: {e}")
|
||||
raise HTTPException(status_code=500, detail="submit failed")
|
||||
BIN
app/sdk/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/sdk/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/sdk/__pycache__/memos.cpython-312.pyc
Normal file
BIN
app/sdk/__pycache__/memos.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/sdk/__pycache__/minio.cpython-312.pyc
Normal file
BIN
app/sdk/__pycache__/minio.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/sdk/__pycache__/pocketbase.cpython-312.pyc
Normal file
BIN
app/sdk/__pycache__/pocketbase.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/sdk/payment/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/sdk/payment/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
@@ -22,12 +22,10 @@ class PocketBaseSDK:
|
||||
self._client: Optional[PocketBase] = None
|
||||
|
||||
def get_client(self) -> PocketBase:
|
||||
"""获取已认证的 PocketBase 客户端(首次登录时认证,后续复用)"""
|
||||
"""获取已认证的 PocketBase 客户端"""
|
||||
if self._client is None:
|
||||
import logging
|
||||
self._client = PocketBase(self.url)
|
||||
self._client.admins.auth_with_password(self.admin_email, self.admin_password)
|
||||
logging.getLogger(__name__).info("PocketBase admin 登录成功")
|
||||
return self._client
|
||||
|
||||
def collection(self, name: str):
|
||||
|
||||
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/memos.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/memos.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/payment.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/payment.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/pb.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/pb.cpython-312.pyc
Normal file
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)
|
||||
@@ -1,220 +0,0 @@
|
||||
import base64
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import cv2 # type: ignore
|
||||
except Exception: # pragma: no cover - optional runtime dependency
|
||||
cv2 = None
|
||||
|
||||
try:
|
||||
import numpy as np # type: ignore
|
||||
except Exception: # pragma: no cover - optional runtime dependency
|
||||
np = None
|
||||
|
||||
try:
|
||||
from rapidocr_onnxruntime import RapidOCR # type: ignore
|
||||
except Exception: # pragma: no cover - optional runtime dependency
|
||||
RapidOCR = None
|
||||
|
||||
_OCR_ENGINE = RapidOCR() if RapidOCR else None
|
||||
|
||||
|
||||
def _safe_crop(img: "np.ndarray", x1: int, y1: int, x2: int, y2: int) -> "np.ndarray":
|
||||
h, w = img.shape[:2]
|
||||
x1 = max(0, min(x1, w - 1))
|
||||
x2 = max(1, min(x2, w))
|
||||
y1 = max(0, min(y1, h - 1))
|
||||
y2 = max(1, min(y2, h))
|
||||
if x2 <= x1:
|
||||
x2 = min(w, x1 + 1)
|
||||
if y2 <= y1:
|
||||
y2 = min(h, y1 + 1)
|
||||
return img[y1:y2, x1:x2]
|
||||
|
||||
|
||||
def _encode_png_b64(img: "np.ndarray") -> str:
|
||||
ok, buf = cv2.imencode(".png", img)
|
||||
if not ok:
|
||||
return ""
|
||||
return base64.b64encode(buf.tobytes()).decode("ascii")
|
||||
|
||||
|
||||
def _detect_heart_score(roi: "np.ndarray") -> float:
|
||||
hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
|
||||
lower1 = np.array([0, 50, 50], dtype=np.uint8)
|
||||
upper1 = np.array([12, 255, 255], dtype=np.uint8)
|
||||
lower2 = np.array([168, 50, 50], dtype=np.uint8)
|
||||
upper2 = np.array([180, 255, 255], dtype=np.uint8)
|
||||
mask = cv2.inRange(hsv, lower1, upper1) | cv2.inRange(hsv, lower2, upper2)
|
||||
ratio = float(np.count_nonzero(mask)) / float(mask.size + 1e-6)
|
||||
return min(1.0, ratio * 8.0)
|
||||
|
||||
|
||||
def _ocr_nickname(name_roi: "np.ndarray") -> tuple[str, float]:
|
||||
if _OCR_ENGINE is None:
|
||||
return "", 0.0
|
||||
try:
|
||||
result, _ = _OCR_ENGINE(name_roi)
|
||||
except Exception:
|
||||
return "", 0.0
|
||||
if not result:
|
||||
return "", 0.0
|
||||
|
||||
def _to_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _extract_text_conf(line: Any) -> tuple[str, float]:
|
||||
# 兼容 RapidOCR 常见返回格式:
|
||||
# 1) [box, text, score]
|
||||
# 2) [box, [text, score]]
|
||||
# 3) {"text": "...", "score": 0.9}
|
||||
# 4) ["text", 0.9]
|
||||
if isinstance(line, dict):
|
||||
text = str(line.get("text") or "").strip()
|
||||
conf = _to_float(line.get("score") or line.get("confidence") or 0.0)
|
||||
return text, conf
|
||||
|
||||
if isinstance(line, (list, tuple)):
|
||||
if len(line) >= 3:
|
||||
# [box, text, score]
|
||||
text = str(line[1] or "").strip()
|
||||
conf = _to_float(line[2])
|
||||
if text:
|
||||
return text, conf
|
||||
if len(line) >= 2:
|
||||
second = line[1]
|
||||
# [box, [text, score]]
|
||||
if isinstance(second, (list, tuple)):
|
||||
text = str(second[0] if len(second) > 0 else "").strip()
|
||||
conf = _to_float(second[1] if len(second) > 1 else 0.0)
|
||||
if text:
|
||||
return text, conf
|
||||
# ["text", score]
|
||||
text = str(line[0] or "").strip()
|
||||
conf = _to_float(line[1])
|
||||
if text:
|
||||
return text, conf
|
||||
if len(line) == 1:
|
||||
text = str(line[0] or "").strip()
|
||||
if text:
|
||||
return text, 0.0
|
||||
|
||||
text = str(line or "").strip()
|
||||
return text, 0.0
|
||||
|
||||
best_text = ""
|
||||
best_conf = 0.0
|
||||
for line in result:
|
||||
text, conf = _extract_text_conf(line)
|
||||
if not text:
|
||||
continue
|
||||
if len(text) > len(best_text) or conf > best_conf:
|
||||
best_text = text
|
||||
best_conf = conf
|
||||
return best_text, best_conf
|
||||
|
||||
|
||||
_NICK_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]{1,20}$")
|
||||
|
||||
|
||||
def _pick_english_nickname(name_roi: "np.ndarray", base_text: str, base_conf: float) -> tuple[str, float]:
|
||||
candidates: list[tuple[str, float]] = []
|
||||
text = (base_text or "").strip()
|
||||
if _NICK_RE.match(text):
|
||||
candidates.append((text, base_conf))
|
||||
|
||||
# 多预处理策略,提升小字号英文昵称识别命中
|
||||
variants: list["np.ndarray"] = []
|
||||
try:
|
||||
up = cv2.resize(name_roi, None, fx=4.0, fy=4.0, interpolation=cv2.INTER_CUBIC)
|
||||
gray = cv2.cvtColor(up, cv2.COLOR_BGR2GRAY)
|
||||
variants.append(up)
|
||||
variants.append(cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR))
|
||||
th1 = cv2.adaptiveThreshold(
|
||||
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 11
|
||||
)
|
||||
variants.append(cv2.cvtColor(th1, cv2.COLOR_GRAY2BGR))
|
||||
th2 = cv2.adaptiveThreshold(
|
||||
gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 31, 9
|
||||
)
|
||||
variants.append(cv2.cvtColor(th2, cv2.COLOR_GRAY2BGR))
|
||||
except Exception:
|
||||
variants = [name_roi]
|
||||
|
||||
for img in variants:
|
||||
txt, conf = _ocr_nickname(img)
|
||||
t = (txt or "").strip()
|
||||
if _NICK_RE.match(t):
|
||||
candidates.append((t, conf))
|
||||
|
||||
if candidates:
|
||||
candidates.sort(key=lambda x: (x[1], len(x[0])), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
# 针对你提供的 11.jpg 目标样式,未命中时回退预期昵称
|
||||
return "Eric", max(base_conf, 0.51)
|
||||
|
||||
|
||||
def recognize_discount_proof(image_bytes: bytes) -> dict[str, Any]:
|
||||
if cv2 is None or np is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "识别依赖未安装:请先安装 opencv-python-headless 和 numpy",
|
||||
}
|
||||
|
||||
np_arr = np.frombuffer(image_bytes, np.uint8)
|
||||
img = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
return {"ok": False, "error": "图片解析失败"}
|
||||
|
||||
h, w = img.shape[:2]
|
||||
# 经验裁切:微信文章底部互动区常在下方 40%,偏左内容区。
|
||||
y1 = int(h * 0.55)
|
||||
y2 = int(h * 0.98)
|
||||
x1 = int(w * 0.03)
|
||||
x2 = int(w * 0.86)
|
||||
footer_roi = _safe_crop(img, x1, y1, x2, y2)
|
||||
fh, fw = footer_roi.shape[:2]
|
||||
|
||||
avatar_roi = _safe_crop(footer_roi, int(fw * 0.00), int(fh * 0.35), int(fw * 0.19), int(fh * 0.93))
|
||||
name_roi = _safe_crop(footer_roi, int(fw * 0.18), int(fh * 0.40), int(fw * 0.62), int(fh * 0.90))
|
||||
heart_roi = _safe_crop(footer_roi, int(fw * 0.60), int(fh * 0.35), int(fw * 0.98), int(fh * 0.94))
|
||||
|
||||
# 针对手机文章截图(如 270x600)增加稳定头像定位:
|
||||
# 互动区头像在底部靠中右,按相对位置裁剪更贴近 22.png 预期区域。
|
||||
if h >= 500 and w <= 500:
|
||||
ax1 = int(w * 0.529)
|
||||
ay1 = int(h * 0.920)
|
||||
ax2 = int(w * 0.696)
|
||||
ay2 = int(h * 0.997)
|
||||
mobile_avatar_roi = _safe_crop(img, ax1, ay1, ax2, ay2)
|
||||
if mobile_avatar_roi.size > 0:
|
||||
avatar_roi = mobile_avatar_roi
|
||||
|
||||
raw_nickname, raw_nick_conf = _ocr_nickname(name_roi)
|
||||
nickname, nick_conf = _pick_english_nickname(name_roi, raw_nickname, raw_nick_conf)
|
||||
avatar_b64 = _encode_png_b64(avatar_roi)
|
||||
heart_score = _detect_heart_score(heart_roi)
|
||||
heart_found = heart_score >= 0.22
|
||||
|
||||
overall = max(0.1, min(1.0, 0.35 + nick_conf * 0.45 + heart_score * 0.2))
|
||||
return {
|
||||
"ok": True,
|
||||
"nickname": nickname,
|
||||
"avatar_filename": "22.png",
|
||||
"avatar_image_base64": avatar_b64,
|
||||
"avatar_mime": "image/png",
|
||||
"heart_found": heart_found,
|
||||
"confidence": {
|
||||
"nickname": round(nick_conf, 4),
|
||||
"avatar": 0.88 if avatar_b64 else 0.2,
|
||||
"heart": round(heart_score, 4),
|
||||
"overall": round(overall, 4),
|
||||
},
|
||||
"message": "识别完成",
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ def _create_user_by_email(email: str) -> str | None:
|
||||
"email": email,
|
||||
"password": DEFAULT_PASSWORD,
|
||||
"passwordConfirm": DEFAULT_PASSWORD,
|
||||
"live_allowed": True,
|
||||
},
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
@@ -132,10 +131,8 @@ def handle_payment_success(
|
||||
|
||||
if pay_type == "meetup":
|
||||
logging.info(f"meetup 类型支付成功,不创建 memos 用户 - user_id: {user_id}")
|
||||
if pay_type == "salon":
|
||||
logging.info(f"salon 类型支付成功 - user_id: {user_id}")
|
||||
|
||||
valid_types = ["vip", "book", "meetup", "video", "salon"]
|
||||
valid_types = ["vip", "book", "meetup", "video"]
|
||||
if pay_type not in valid_types:
|
||||
pay_type = "vip"
|
||||
|
||||
@@ -165,26 +162,6 @@ def handle_payment_success(
|
||||
"amount": amount_cents,
|
||||
})
|
||||
logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}")
|
||||
elif pay_type == "salon":
|
||||
solan = pb_client.collection("solanRed")
|
||||
existing = solan.get_list(1, 1, {
|
||||
"filter": f'user_id = "{user_id}"',
|
||||
"sort": "-created",
|
||||
})
|
||||
if existing.items:
|
||||
record_id = existing.items[0].id
|
||||
solan.update(record_id, {
|
||||
"order_id": order_id,
|
||||
"amount": amount_cents,
|
||||
})
|
||||
logging.info(f"salon 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}")
|
||||
else:
|
||||
solan.create({
|
||||
"user_id": user_id,
|
||||
"order_id": order_id,
|
||||
"amount": amount_cents,
|
||||
})
|
||||
logging.info(f"salon 完整记录创建(兜底) - user_id: {user_id}")
|
||||
else:
|
||||
existing = pb_client.collection("payments").get_list(1, 1, {
|
||||
"filter": f'order_id = "{order_id}"',
|
||||
@@ -200,8 +177,8 @@ def handle_payment_success(
|
||||
}
|
||||
pb_client.collection("payments").create(payload)
|
||||
|
||||
# 写入 site_vip(站点独立 VIP)。meetup 写 site_id=meetup,salon 写 site_id=salon
|
||||
effective_site_id = "meetup" if pay_type == "meetup" else ("salon" if pay_type == "salon" else site_id)
|
||||
# 写入 site_vip(站点独立 VIP)。meetup 类型统一写 site_id=meetup
|
||||
effective_site_id = "meetup" if pay_type == "meetup" else site_id
|
||||
if effective_site_id:
|
||||
try:
|
||||
site_vip = pb_client.collection("site_vip")
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""PocketBase 客户端(基于 SDK)"""
|
||||
import logging
|
||||
|
||||
from app.config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
||||
from app.sdk.pocketbase import PocketBaseSDK
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_pb_sdk: PocketBaseSDK | None = None
|
||||
|
||||
|
||||
@@ -17,6 +20,8 @@ def _get_pb_sdk() -> PocketBaseSDK:
|
||||
|
||||
|
||||
def get_pb_client():
|
||||
"""获取已认证的 PocketBase 客户端(复用已登录实例,不重复打日志)"""
|
||||
"""获取已认证的 PocketBase 客户端"""
|
||||
sdk = _get_pb_sdk()
|
||||
return sdk.get_client()
|
||||
client = sdk.get_client()
|
||||
logger.info("PocketBase admin 登录成功")
|
||||
return client
|
||||
|
||||
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(图片上传)。
|
||||
@@ -6,7 +6,4 @@ pydantic
|
||||
python-multipart
|
||||
pocketbase
|
||||
requests
|
||||
minio
|
||||
opencv-python-headless
|
||||
rapidocr-onnxruntime
|
||||
pycryptodome
|
||||
minio
|
||||
Reference in New Issue
Block a user