feat: complete huihui square avatar workflows

This commit is contained in:
stefanfeng
2026-07-24 14:04:21 +08:00
parent 2ef58a44b8
commit e3bda469bb
68 changed files with 8062 additions and 132 deletions

View File

@@ -7,7 +7,7 @@ import uuid
import hashlib
import hmac
from datetime import datetime, timedelta
from typing import Optional
from typing import Optional, Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
@@ -45,6 +45,31 @@ class NewsPlatformService:
"orgId": await self._cfg(db, "platform_org_id", ""),
}
@staticmethod
def _api_root(base_url: str) -> str:
if "/api/" in base_url:
return base_url.split("/api/", 1)[0] + "/api"
return base_url.rstrip("/")
@staticmethod
def _resolve_synced_nickname(user: VirtualUser, sync_nickname: str, sync_real_name: str) -> str:
"""避免登录同步把有意义的本地昵称覆盖回手机号。"""
current_nickname = (user.nickname or "").strip()
current_real_name = (user.real_name or "").strip()
sync_nickname = (sync_nickname or "").strip()
sync_real_name = (sync_real_name or "").strip()
account = (user.account or "").strip()
if current_nickname and current_nickname != account:
return current_nickname
if sync_nickname and sync_nickname != account:
return sync_nickname
if sync_real_name:
return sync_real_name
if current_real_name:
return current_real_name
return sync_nickname or current_nickname or account
# ─── 签名(完全对应 sign.js 逻辑) ─────────────────────────
@staticmethod
def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str:
@@ -188,6 +213,7 @@ class NewsPlatformService:
sync_real_name = user_info.get("realName") or ""
sync_sex = int(user_info.get("sex") or 0)
sync_avatar = user_info.get("avatar") or ""
preferred_nickname = self._resolve_synced_nickname(user, sync_nickname, sync_real_name)
await set_session(user.id, {
"token": access_token,
@@ -196,7 +222,7 @@ class NewsPlatformService:
"org_id": org_id or cfg.get("orgId", ""),
"login_time": datetime.now().isoformat(),
# 缓存用户信息供 sync 使用
"nickname": sync_nickname,
"nickname": preferred_nickname,
"real_name": sync_real_name,
"sex": sync_sex,
"avatar": sync_avatar,
@@ -209,7 +235,7 @@ class NewsPlatformService:
last_login_at=datetime.now(),
platform_uid=platform_uid,
)
if sync_nickname: update_vals["nickname"] = sync_nickname
if preferred_nickname: update_vals["nickname"] = preferred_nickname
if sync_real_name: update_vals["real_name"] = sync_real_name
if sync_sex: update_vals["sex"] = sync_sex
if sync_avatar: update_vals["avatar_url"] = sync_avatar
@@ -719,51 +745,287 @@ class NewsPlatformService:
return False
async def post_comment(self, db, user, news_id, news_title, content, news_author_id="", org_id="") -> tuple[bool, str]:
success, err, _ = await self.post_comment_with_record_id(
db, user, news_id, news_title, content,
news_author_id=news_author_id, org_id=org_id,
)
return success, err
async def post_comment_with_record_id(
self, db, user, news_id, news_title, content, news_author_id="", org_id=""
) -> tuple[bool, str, str]:
sess = await get_session(user.id)
if not sess:
return False, "未登录"
return False, "未登录", ""
biz = await self._biz_url(db)
cfg = await self._client(db)
uid = sess.get("platform_uid", "")
# org_id 优先取文章自带的(从广场数据获取),否则取 session/配置
final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or ""
if not final_org_id:
final_org_id = await self._get_article_org_id(db, sess["token"], news_id)
body = {
"module": "news", "topicId": news_id, "title": news_title,
"content": content, "orgId": final_org_id,
"toUserId": news_author_id or uid, "userId": uid,
"userName": user.nickname, "avatar": user.avatar_url or "",
}
return await self._json_post(f"{biz}/message/comment", self._bearer(sess["token"]), body)
success, err, record_id = await self._json_post_with_record_id(
f"{biz}/message/comment", self._bearer(sess["token"]), body
)
if success and not record_id:
record_id, _ = await self.find_comment_id(db, user, news_id, content)
return success, err, record_id
async def post_reply(self, db, user, news_id, comment_id, content) -> tuple[bool, str]:
async def post_reply(
self, db, user, news_id, comment_id, content,
parent_comment: dict | None = None,
reply_to: dict | None = None,
article_title: str = "",
org_id: str = "",
) -> tuple[bool, str]:
success, err, _ = await self.post_reply_with_record_id(
db, user, news_id, comment_id, content,
parent_comment=parent_comment,
reply_to=reply_to,
article_title=article_title,
org_id=org_id,
)
return success, err
async def post_reply_with_record_id(
self, db, user, news_id, comment_id, content,
parent_comment: dict | None = None,
reply_to: dict | None = None,
article_title: str = "",
org_id: str = "",
) -> tuple[bool, str, str]:
sess = await get_session(user.id)
if not sess:
return False, "未登录"
return False, "未登录", ""
biz = await self._biz_url(db)
uid = sess.get("platform_uid", "")
cfg = await self._client(db)
parent_comment = parent_comment or {}
reply_to = reply_to or None
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or comment_id or "")
parent_user_id = str(
parent_comment.get("createUser")
or parent_comment.get("userId")
or parent_comment.get("commentUserId")
or parent_comment.get("fromUserId")
or ""
)
parent_user_name = (
parent_comment.get("userName")
or parent_comment.get("fromUserName")
or parent_comment.get("nickName")
or ""
)
reply_target = reply_to or parent_comment
reply_target_id = str(
reply_target.get("id")
or reply_target.get("replyId")
or reply_target.get("commentId")
or parent_comment_id
)
reply_target_user_id = str(
reply_target.get("createUser")
or reply_target.get("fromUserId")
or reply_target.get("userId")
or reply_target.get("commentUserId")
or parent_user_id
or ""
)
reply_target_user_name = (
reply_target.get("fromUserName")
or reply_target.get("userName")
or reply_target.get("nickName")
or parent_user_name
or ""
)
from_user_name = (
(user.real_name or "").strip()
or (user.nickname or "").strip()
or (user.account or "").strip()
)
final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or ""
if not final_org_id:
final_org_id = await self._get_article_org_id(db, sess["token"], news_id)
body = {
"module": "news", "topicId": news_id, "commentId": comment_id,
"commentUserId": uid, "content": content,
"fromUserName": user.nickname, "avatar": user.avatar_url or "",
"module": "news",
"modules": "news",
"topicId": news_id,
"commentId": parent_comment_id,
"commentUserId": parent_user_id,
"replyId": reply_target_id,
"toUserId": reply_target_user_id,
"replyType": "2" if reply_to else "1",
"toUserName": reply_target_user_name,
"content": content,
"title": article_title or parent_comment.get("title") or "",
"orgId": final_org_id,
"fromUserName": from_user_name,
"avatar": user.avatar_url or "",
}
return await self._json_post(f"{biz}/message/comment/reply", self._bearer(sess["token"]), body)
success, err, reply_id = await self._json_post_with_record_id(
f"{biz}/message/comment/reply", self._bearer(sess["token"]), body
)
if success and not reply_id:
reply_id, _ = await self.find_reply_id(
db, user, news_id,
content=content,
parent_comment_id=parent_comment_id,
)
return success, err, reply_id
async def _get_article_org_id(self, db, token: str, news_id: str) -> str:
"""从文章详情补齐 H5 评论/回复所需的组织上下文。"""
if not news_id:
return ""
biz = await self._biz_url(db)
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.get(f"{biz}/news/{news_id}", headers=self._bearer(token))
if r.status_code != 200:
return ""
data = (r.json().get("data") or {})
return str(
data.get("orgId")
or data.get("alumnusId")
or data.get("publishOrgId")
or data.get("initialPublishOrgId")
or ""
)
except Exception:
return ""
async def get_comments(self, db, user, news_id) -> list:
data = await self.get_comments_payload(db, user, news_id)
if isinstance(data, dict):
rows = data.get("data") or data.get("records") or data.get("list") or data.get("rows")
return rows if isinstance(rows, list) else []
return data if isinstance(data, list) else []
async def get_comments_payload(self, db, user, news_id) -> Any:
sess = await get_session(user.id)
if not sess:
return []
biz = await self._biz_url(db)
cfg = await self._client(db)
try:
params = self._build_form({"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20}, cfg)
params = {"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20}
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{biz}/message/comment", headers=self._bearer(sess["token"]), params=params)
r = await c.get(
f"{self._api_root(biz)}/interaction/open/selectInteractionCommentList",
headers=self._bearer(sess["token"]),
params=params,
)
if r.status_code == 200:
return r.json().get("data", {}).get("data") or []
return r.json().get("data") or []
except Exception:
pass
return []
async def find_comment_id(self, db, user, news_id: str, content: str = "") -> tuple[str, int | None]:
"""Best-effort lookup for old records created before platform IDs were saved."""
sess = await get_session(user.id)
my_uid = str(sess.get("platform_uid", "")) if sess else ""
payload = await self.get_comments_payload(db, user, news_id)
comment_count = None
comments = payload if isinstance(payload, list) else []
if isinstance(payload, dict):
try:
comment_count = int(payload.get("commentCount"))
except (TypeError, ValueError):
comment_count = None
comments = (
payload.get("data")
or payload.get("records")
or payload.get("list")
or payload.get("rows")
or []
)
if not isinstance(comments, list):
comments = []
target_content = (content or "").strip()
for item in comments:
if not isinstance(item, dict):
continue
author_id = str(item.get("userId") or item.get("createUser") or item.get("commentUserId") or "")
item_content = (item.get("content") or "").strip()
if my_uid and author_id and author_id != my_uid:
continue
if target_content and item_content != target_content:
continue
comment_id = str(item.get("id") or item.get("commentId") or item.get("messageId") or "")
if comment_id:
return comment_id, comment_count
return "", comment_count
async def find_reply_id(
self, db, user, news_id: str, content: str = "", parent_comment_id: str = ""
) -> tuple[str, int | None]:
"""Best-effort lookup for reply records created before platform IDs were saved."""
sess = await get_session(user.id)
my_uid = str(sess.get("platform_uid", "")) if sess else ""
payload = await self.get_comments_payload(db, user, news_id)
reply_count = None
comments = payload if isinstance(payload, list) else []
if isinstance(payload, dict):
for count_key in ("replyCount", "commentCount", "total"):
try:
if payload.get(count_key) is not None:
reply_count = int(payload.get(count_key))
break
except (TypeError, ValueError):
reply_count = None
comments = (
payload.get("data")
or payload.get("records")
or payload.get("list")
or payload.get("rows")
or []
)
if not isinstance(comments, list):
comments = []
target_content = (content or "").strip()
target_parent_id = str(parent_comment_id or "")
for item in comments:
if not isinstance(item, dict):
continue
current_parent_id = str(item.get("id") or item.get("commentId") or "")
if target_parent_id and current_parent_id != target_parent_id:
continue
replies = (
item.get("respList")
or item.get("replyList")
or item.get("children")
or item.get("replies")
or []
)
if not isinstance(replies, list):
continue
for reply in replies:
if not isinstance(reply, dict):
continue
author_id = str(
reply.get("fromUserId")
or reply.get("createUser")
or reply.get("userId")
or reply.get("commentUserId")
or ""
)
reply_content = (reply.get("content") or "").strip()
if my_uid and author_id and author_id != my_uid:
continue
if target_content and reply_content != target_content:
continue
reply_id = str(reply.get("id") or reply.get("replyId") or reply.get("commentId") or "")
if reply_id:
return reply_id, reply_count
return "", reply_count
async def like_news(self, db, user, news_id, org_id="", to_user_id="", title="") -> tuple[bool, str]:
sess = await get_session(user.id)
if not sess:
@@ -825,13 +1087,41 @@ class NewsPlatformService:
return True, ""
return False, f"HTTP {resp.status_code}"
@staticmethod
def _extract_record_id(data: Any) -> str:
if data is None:
return ""
if isinstance(data, (str, int)):
return str(data)
if isinstance(data, dict):
for key in ("id", "replyId", "commentId", "messageId", "recordId"):
if data.get(key):
return str(data[key])
for key in ("data", "record", "reply", "comment", "message"):
nested = NewsPlatformService._extract_record_id(data.get(key))
if nested:
return nested
return ""
async def _json_post(self, url, headers, body) -> tuple[bool, str]:
success, err, _ = await self._json_post_with_record_id(url, headers, body)
return success, err
async def _json_post_with_record_id(self, url, headers, body) -> tuple[bool, str, str]:
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(url, json=body, headers=headers)
return self._ok(r)
if r.status_code not in [200, 201]:
return False, f"HTTP {r.status_code}", ""
try:
d = r.json()
except Exception:
return True, "", ""
if d.get("code") in [0, 200]:
return True, "", self._extract_record_id(d.get("data"))
return False, d.get("message") or "业务失败", ""
except Exception as e:
return False, str(e)
return False, str(e), ""
async def _write_login_log(self, db, user, action, session_id=None, error_msg=None):
try:
@@ -898,6 +1188,25 @@ class NewsPlatformService:
except Exception as e:
return False, str(e)
async def cancel_reply(self, db, user, news_id: str, reply_id: str) -> tuple[bool, str]:
"""DELETE /message/comment/reply/{topicId}/{id} 删除评论回复"""
sess = await get_session(user.id)
if not sess:
return False, "未登录"
biz = await self._biz_url(db)
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.delete(
f"{biz}/message/comment/reply/{news_id}/{reply_id}",
headers=self._bearer(sess["token"]),
)
d = r.json()
if d.get("code") in [0, 200]:
return True, ""
return False, d.get("message", "删除回复失败")
except Exception as e:
return False, str(e)
async def cancel_collect(self, db, user, news_id: str, org_id: str = "", to_user_id: str = "", title: str = "") -> tuple[bool, str]:
"""取消收藏(复用取消点赞接口)"""
return await self.cancel_like(db, user, news_id, org_id=org_id, to_user_id=to_user_id, title=title)