1 Commits
test ... memos

Author SHA1 Message Date
eric
18d098f5f8 'memos' 2026-03-15 04:26:05 -05:00
50 changed files with 1410 additions and 2353 deletions

View File

@@ -18,9 +18,7 @@ ZPAY_PID=2025121809351743
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
# PocketBasecheck-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@

View File

@@ -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@
# Memosnomadvip 支付成功后创建账号)
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
View File

@@ -11,8 +11,3 @@ venv/
.venv/
# .env
# .env.local
__pycache__/
*.py[cod]
*.pyo
*.pyd

View File

@@ -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
View File

@@ -0,0 +1 @@
# Database modules

753
app/db/community_sqlite.py Normal file
View 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}]({url})",
"filename": stored_name,
"original_name": filename,
"content_type": content_type,
"size": len(data),
"created": now,
}
def list_uploads(page: int = 1, per_page: int = 24, user_id: str = "") -> dict:
offset = (page - 1) * per_page
conn = _get_conn()
try:
where_clause = ""
params: list[str] = []
if user_id:
where_clause = "WHERE user_id = ?"
params.append(user_id)
total = int(
conn.execute(f"SELECT COUNT(*) FROM community_uploads {where_clause}", params).fetchone()[0] or 0
)
rows = conn.execute(
f"""
SELECT id, user_id, original_name, filename, content_type, size, url, created
FROM community_uploads
{where_clause}
ORDER BY created DESC
LIMIT ? OFFSET ?
""",
[*params, per_page, offset],
).fetchall()
items = [
{
"id": row["id"],
"user_id": row["user_id"],
"filename": row["filename"],
"original_name": row["original_name"],
"content_type": row["content_type"],
"size": row["size"],
"url": row["url"],
"created": row["created"],
}
for row in rows
]
next_token = "" if len(items) < per_page else str(page + 1)
return {
"items": items,
"totalItems": total,
"page": page,
"pageSize": per_page,
"next_page_token": next_token,
}
finally:
conn.close()
def get_stats(member_count: int | None = None) -> dict:
conn = _get_conn()
try:
totals = conn.execute(
"""
SELECT
COUNT(*) AS total_memos,
SUM(CASE WHEN state = 'NORMAL' THEN 1 ELSE 0 END) AS normal_memos,
SUM(CASE WHEN state = 'ARCHIVED' THEN 1 ELSE 0 END) AS archived_memos,
SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END) AS pinned_memos,
COUNT(DISTINCT user_id) AS contributors
FROM community_memos
"""
).fetchone()
total_uploads = int(conn.execute("SELECT COUNT(*) FROM community_uploads").fetchone()[0] or 0)
total_comments = int(conn.execute("SELECT COUNT(*) FROM community_comments").fetchone()[0] or 0)
total_reactions = int(conn.execute("SELECT COUNT(*) FROM community_reactions").fetchone()[0] or 0)
today_prefix = _now_iso()[:10]
today_memos = int(
conn.execute("SELECT COUNT(*) FROM community_memos WHERE created LIKE ?", (f"{today_prefix}%",)).fetchone()[0]
or 0
)
top_authors_rows = conn.execute(
"""
SELECT user_id, COUNT(*) AS memo_count, MAX(created) AS latest_created
FROM community_memos
WHERE state = 'NORMAL'
GROUP BY user_id
ORDER BY memo_count DESC, latest_created DESC
LIMIT 6
"""
).fetchall()
top_authors = [
{
"user_id": row["user_id"],
"memo_count": row["memo_count"],
"latest_created": row["latest_created"],
}
for row in top_authors_rows
]
tag_counter: Counter[str] = Counter()
for row in conn.execute("SELECT content FROM community_memos WHERE state = 'NORMAL'").fetchall():
tag_counter.update(_extract_tags(row["content"]))
top_tags = [{"tag": tag, "count": count} for tag, count in tag_counter.most_common(10)]
latest_rows = conn.execute(
"""
SELECT id, user_id, content, pinned, visibility, state, created, updated
FROM community_memos
WHERE state = 'NORMAL'
ORDER BY pinned DESC, created DESC
LIMIT 3
"""
).fetchall()
latest_memos = [_normalize_memo(_enrich_memo(conn, _base_memo_from_row(row))) for row in latest_rows]
return {
"stats": {
"members": member_count,
"total_memos": int(totals["total_memos"] or 0),
"normal_memos": int(totals["normal_memos"] or 0),
"archived_memos": int(totals["archived_memos"] or 0),
"pinned_memos": int(totals["pinned_memos"] or 0),
"contributors": int(totals["contributors"] or 0),
"attachments": total_uploads,
"comments": total_comments,
"reactions": total_reactions,
"today_memos": today_memos,
},
"top_tags": top_tags,
"top_authors": top_authors,
"latest_memos": latest_memos,
}
finally:
conn.close()
def get_upload_path(filename: str) -> Path | None:
if not filename or ".." in filename or "/" in filename or "\\" in filename:
return None
path = UPLOADS_DIR / filename
if not path.exists() or not path.is_file():
return None
return path

View File

@@ -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",
},
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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}

View File

@@ -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",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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")

View File

@@ -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 服务保持一致thirdapiURL 带 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,"&lt;").replace(/>/g,"&gt;");
const uid = (u.userId || "").replace(/</g,"&lt;").replace(/>/g,"&gt;");
const last = (u.lastMsg || "").replace(/</g,"&lt;").replace(/>/g,"&gt;");
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
View 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))

View File

@@ -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

View File

@@ -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")

View File

@@ -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")

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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):

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

116
app/services/community.py Normal file
View 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)

View File

@@ -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": "识别完成",
}

View File

@@ -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=meetupsalon 写 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")

View File

@@ -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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,21 @@
# 私密社群 PocketBase 集合 schema
在 PocketBase 管理后台创建集合 `community_memos`,字段如下:
| 字段名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| user_id | Text | ✓ | 发布者 user_idPocketBase 用户 ID 或匿名 user123456 |
| content | Text | ✓ | 动态内容,支持 Markdown、#标签、图片 `![](url)` |
| pinned | Bool | | 是否置顶,默认 false |
| visibility | Text | | 可见性PRIVATE/PROTECTED/PUBLIC默认 PRIVATE |
| state | Text | | 状态NORMAL/ARCHIVED默认 NORMAL |
- 集合名:`community_memos`
- 列表/查看权限:仅服务端 admin 操作,前端通过 payjsapi `/community/memos` 接口访问
- 创建/更新/删除:仅 VIP 用户,由 payjsapi 校验
创建步骤:
1. 登录 PocketBase 管理后台
2. Collections → New collection → 名称 `community_memos`
3. 添加字段user_id (Text), content (Editor 或 Text), pinned (Bool)
4. 保存

45
docs/COMMUNITY_SQLITE.md Normal file
View 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图片上传

View File

@@ -6,7 +6,4 @@ pydantic
python-multipart
pocketbase
requests
minio
opencv-python-headless
rapidocr-onnxruntime
pycryptodome
minio