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

@@ -52,20 +52,45 @@ async def retry_interaction(record_id: int, db=Depends(get_db)):
if not user or user.status != 2:
raise HTTPException(status_code=400, detail="用户未登录,无法重试")
success, err = False, "未知类型"
success, err, platform_record_id = False, "未知类型", ""
if record.interact_type == "comment" and record.content:
success, err = await news_service.post_comment(db, user, record.article_id, record.article_title or "", record.content)
success, err, platform_record_id = await news_service.post_comment_with_record_id(
db, user, record.article_id, record.article_title or "", record.content
)
elif record.interact_type == "like":
success, err = await news_service.like_news(db, user, record.article_id, org_id="", title=record.article_title or "")
elif record.interact_type == "collect":
success, err = await news_service.collect_news(db, user, record.article_id, title=record.article_title or "")
elif record.interact_type == "forward":
success, err = await news_service.forward_news(db, user, record.article_id)
elif record.interact_type == "reply" and record.content:
parent_comment = None
if record.parent_comment_id:
comments = await news_service.get_comments(db, user, record.article_id or "")
parent_comment = next(
(
c for c in comments
if str(c.get("id") or c.get("commentId") or "") == str(record.parent_comment_id)
),
None,
)
if not parent_comment:
success, err, platform_record_id = False, "未找到被回复的原评论,无法重试回复", ""
else:
success, err, platform_record_id = await news_service.post_reply_with_record_id(
db, user,
record.article_id or "",
record.parent_comment_id or "",
record.content,
parent_comment=parent_comment,
article_title=record.article_title or "",
)
await db.execute(
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
status=1 if success else 2,
error_msg=None if success else err,
platform_record_id=platform_record_id or record.platform_record_id,
retry_count=record.retry_count + 1,
)
)
@@ -147,15 +172,69 @@ async def cancel_interaction(record_id: int, db=Depends(get_db)):
news_id=record.article_id or "",
title=record.article_title or "",
)
elif record.interact_type in ("comment", "reply"):
elif record.interact_type == "comment":
comment_id = record.platform_record_id or ""
if not comment_id:
return ApiResponse(code=400, message="缺少评论ID无法删除")
comment_id, comment_count = await news_service.find_comment_id(
db, user,
news_id=record.article_id or "",
content=record.content or "",
)
if comment_id:
await db.execute(
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
platform_record_id=comment_id
)
)
await db.commit()
elif comment_count == 0:
await db.execute(
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
status=3,
error_msg="平台未查询到评论,已标记取消",
)
)
await db.commit()
return ApiResponse(message="平台未查询到评论,已标记取消")
else:
return ApiResponse(code=400, message="缺少评论ID且未能从平台评论列表反查到该评论")
ok, err = await news_service.cancel_comment(
db, user,
news_id=record.article_id or "",
comment_id=comment_id,
)
elif record.interact_type == "reply":
reply_id = record.platform_record_id or ""
if not reply_id:
reply_id, reply_count = await news_service.find_reply_id(
db, user,
news_id=record.article_id or "",
content=record.content or "",
parent_comment_id=record.parent_comment_id or "",
)
if reply_id:
await db.execute(
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
platform_record_id=reply_id
)
)
await db.commit()
elif reply_count == 0:
await db.execute(
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
status=3,
error_msg="平台未查询到回复,已标记取消",
)
)
await db.commit()
return ApiResponse(message="平台未查询到回复,已标记取消")
else:
return ApiResponse(code=400, message="缺少回复ID且未能从平台回复列表反查到该回复")
ok, err = await news_service.cancel_reply(
db, user,
news_id=record.article_id or "",
reply_id=reply_id,
)
if ok:
# 更新状态为手动取消status=3

View File

@@ -1,5 +1,8 @@
"""虚拟用户管理接口"""
from typing import Optional
from pathlib import Path
import uuid
import mimetypes
from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException
from fastapi.responses import StreamingResponse
import io
@@ -9,6 +12,21 @@ from app.schemas import ApiResponse, UserCreateRequest, UserUpdateRequest, UserB
from app.services.user_service import user_service
router = APIRouter()
_UPLOADS_DIR = Path(__file__).resolve().parents[2] / "uploads" / "avatars"
_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
async def _save_local_avatar(file_bytes: bytes, filename: str, content_type: str | None) -> str:
ext = Path(filename or "").suffix.lower()
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
guessed_ext = mimetypes.guess_extension(content_type or "") or ".jpg"
ext = ".jpg" if guessed_ext == ".jpe" else guessed_ext
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
ext = ".jpg"
target = _UPLOADS_DIR / f"{uuid.uuid4().hex}{ext}"
target.write_bytes(file_bytes)
return f"/api/uploads/avatars/{target.name}"
@router.get("")
@@ -268,13 +286,20 @@ async def sync_all_profiles(db=Depends(get_db)):
sess = await get_session(uid)
if not sess:
return False
ur = await s.execute(select(_VU).where(_VU.id == uid))
user = ur.scalar_one_or_none()
if not user:
return False
platform_uid = sess.get("platform_uid", "")
# 登录成功时 session 里已存有用户信息
vals = {}
if platform_uid: vals["platform_uid"] = platform_uid
# session 里的字段(登录时写入)
if sess.get("nickname"): vals["nickname"] = sess["nickname"]
if sess.get("real_name"): vals["real_name"] = sess["real_name"]
sync_nickname = sess.get("nickname", "")
sync_real_name = sess.get("real_name", "")
resolved_nickname = news_service._resolve_synced_nickname(user, sync_nickname, sync_real_name)
if resolved_nickname: vals["nickname"] = resolved_nickname
if sync_real_name: vals["real_name"] = sync_real_name
if sess.get("sex"): vals["sex"] = int(sess["sex"])
if sess.get("avatar"): vals["avatar_url"] = sess["avatar"]
if vals:
@@ -327,9 +352,8 @@ async def upload_avatar(
else:
return ApiResponse(code=500, message=f"头像上传到平台失败: {result}")
else:
# 仅本地存储(转 base64 或存储到本地)
import base64
avatar_url = f"data:{file.content_type};base64,{base64.b64encode(file_bytes).decode()}"
# 未登录用户本地落盘,避免 base64 超过 avatar_url 字段长度
avatar_url = await _save_local_avatar(file_bytes, file.filename or "", file.content_type)
# 更新数据库
await db.execute(update(_VU).where(_VU.id == user_id).values(avatar_url=avatar_url))

View File

@@ -62,7 +62,7 @@ async def init_db():
# 导入所有模型类,确保 SQLAlchemy ORM 元数据注册
from app.models import (
VirtualUser, UserPersonality, InteractionRecord,
TokenStat, AIModelConfig, SystemConfig, LoginLog
PendingReplyTask, TokenStat, AIModelConfig, SystemConfig, LoginLog
)
logger.info("✅ 数据库模型注册成功")
logger.info("✅ 数据库初始化完成")

View File

@@ -6,7 +6,9 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
import re as _re
from pathlib import Path
# 修复 Pydantic v2 把 naive datetime 序列化为 +00:00 的问题
# 数据库存的是北京时间,应标记为 +08:00
@@ -89,6 +91,10 @@ app.add_middleware(
# 注册路由
app.include_router(router, prefix="/api")
uploads_dir = Path(__file__).resolve().parent / "uploads"
uploads_dir.mkdir(parents=True, exist_ok=True)
app.mount("/api/uploads", StaticFiles(directory=uploads_dir), name="uploads")
@app.get("/health")
async def health_check():

View File

@@ -75,6 +75,33 @@ class InteractionRecord(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class PendingReplyTask(Base):
__tablename__ = "pending_reply_tasks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
actor_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
next_actor_user_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
news_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
news_title: Mapped[str | None] = mapped_column(String(256))
article_org_id: Mapped[str | None] = mapped_column(String(64))
parent_comment_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
parent_comment: Mapped[dict | None] = mapped_column(JSON)
reply_to: Mapped[dict | None] = mapped_column(JSON)
root_content: Mapped[str | None] = mapped_column(Text)
context: Mapped[str | None] = mapped_column(Text)
next_probability: Mapped[float] = mapped_column(Float, default=0.3)
next_delay_min_seconds: Mapped[int] = mapped_column(Integer, default=30)
next_delay_max_seconds: Mapped[int] = mapped_column(Integer, default=7200)
status: Mapped[int] = mapped_column(SmallInteger, default=0, index=True) # 0待发送 1发送中 2已发送 3失败
attempts: Mapped[int] = mapped_column(SmallInteger, default=0)
scheduled_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
locked_at: Mapped[datetime | None] = mapped_column(DateTime)
sent_at: Mapped[datetime | None] = mapped_column(DateTime)
last_error: Mapped[str | None] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
class TokenStat(Base):
__tablename__ = "token_stats"

View File

@@ -190,6 +190,32 @@ class AIService:
请对上面的评论写一条友善自然的回复,{word_min}~{word_max}字,直接输出回复内容。"""
return await self._call_api(db, prompt, system_prompt, max_tokens=150)
async def generate_thread_reply(
self, db: AsyncSession, article_title: str, root_comment: str,
reply_context: str, personality_prompt: str,
word_min: int = 15, word_max: int = 60
) -> tuple[str, int]:
"""结合文章、原评论和上下文生成评论回复链中的下一句。"""
system_prompt = f"""你是一名真实的社区用户。{personality_prompt}
重要规则:
- 回复必须积极正面、文明友善,不含任何敏感违规内容
- 要自然接住对方的话,不要机械复述
- 不要透露自己是AI或虚拟用户"""
prompt = f"""文章标题:{article_title}
原评论:{root_comment}
当前对话上下文:{reply_context}
请结合文章、原评论和当前对话,写一条自然的后续回复。
要求:
1. 字数 {word_min}~{word_max}
2. 语气像真实用户交流,可以认同、补充或追问
3. 必须围绕文章和评论内容,不要跑题
4. 只输出回复正文,不要加任何前缀或解释
回复:"""
return await self._call_api(db, prompt, system_prompt, max_tokens=180)
async def test_model(self, db: AsyncSession, model_id: int, test_prompt: str) -> dict:
"""测试模型可用性"""
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))

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)

View File

@@ -1,7 +1,7 @@
"""调度服务 - 定时自动互动、会话校验"""
import random
import asyncio
from datetime import datetime, date
from datetime import datetime, date, timedelta
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
@@ -9,7 +9,7 @@ from sqlalchemy import select, update, func
from app.core.database import AsyncSessionLocal
from app.core.logger import logger
from app.models import VirtualUser, UserPersonality, InteractionRecord, SystemConfig
from app.models import VirtualUser, UserPersonality, InteractionRecord, PendingReplyTask, SystemConfig
class SchedulerService:
@@ -23,6 +23,12 @@ class SchedulerService:
from app.core.database import AsyncSessionLocal
logger.info("⚡ 立即触发互动任务")
async with AsyncSessionLocal() as session:
try:
max_concurrent = int(await self._get_config(session, "max_concurrent_users", "5"))
except (TypeError, ValueError):
max_concurrent = 5
max_concurrent = max(1, max_concurrent)
result_r = await session.execute(
select(VirtualUser).where(
VirtualUser.status == 2,
@@ -31,13 +37,62 @@ class SchedulerService:
)
users = result_r.scalars().all()
if not users:
return {"message": "没有已登录的用户", "triggered": 0}
return {
"message": "没有已登录的用户",
"requested_concurrency": max_concurrent,
"attempted_count": 0,
"success_count": 0,
"skipped_count": 0,
"failed_count": 0,
"triggered": 0,
"users": [],
"results": [],
}
import random
selected = random.sample(users, min(5, len(users)))
selected = random.sample(users, min(max_concurrent, len(users)))
import asyncio
tasks = [self._execute_user_interaction(u.id) for u in selected]
await asyncio.gather(*tasks, return_exceptions=True)
return {"triggered": len(selected), "users": [u.account for u in selected]}
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
results = []
success_count = skipped_count = failed_count = 0
for user, outcome in zip(selected, raw_results):
if isinstance(outcome, Exception):
item = {
"user_id": user.id,
"account": user.account,
"status": "failed",
"reason": str(outcome),
"interactions": [],
}
failed_count += 1
else:
item = outcome or {
"user_id": user.id,
"account": user.account,
"status": "skipped",
"reason": "no_result",
"interactions": [],
}
status = item.get("status")
if status == "success":
success_count += 1
elif status == "failed":
failed_count += 1
else:
skipped_count += 1
results.append(item)
return {
"requested_concurrency": max_concurrent,
"attempted_count": len(selected),
"success_count": success_count,
"skipped_count": skipped_count,
"failed_count": failed_count,
"triggered": len(selected),
"users": [u.account for u in selected],
"results": results,
}
async def start(self):
if self._running:
@@ -52,6 +107,11 @@ class SchedulerService:
self._run_interactions, IntervalTrigger(minutes=5),
id="run_interactions", replace_existing=True
)
# 待发送回复队列:持久化延迟回复,后端重启后可继续发送
self.scheduler.add_job(
self._process_pending_reply_tasks, IntervalTrigger(seconds=30),
id="process_pending_reply_tasks", replace_existing=True
)
# 每日零点重置计数
self.scheduler.add_job(
self._daily_reset, "cron", hour=16, minute=0, # 北京时间 00:00 = UTC 16:00
@@ -221,7 +281,13 @@ class SchedulerService:
user_result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
user = user_result.scalar_one_or_none()
if not user or user.status != 2:
return
return {
"user_id": user_id,
"account": getattr(user, "account", ""),
"status": "skipped",
"reason": "user_not_logged_in",
"interactions": [],
}
# 检查今日评论限额
can_comment = True
@@ -255,7 +321,13 @@ class SchedulerService:
f"用户 {user.account} 获取新闻列表为空 "
f"(orgId={await news_service._cfg(db, 'platform_org_id', '')})"
)
return
return {
"user_id": user.id,
"account": user.account,
"status": "skipped",
"reason": "no_articles",
"interactions": [],
}
# ── 文章去重 + 热度加权选取 ─────────────────────────────────
# 查询今日已互动过的文章(所有类型),避免重复互动同一篇
@@ -307,7 +379,14 @@ class SchedulerService:
article_org_id = str(article.get("orgId") or "")
if not news_id:
return
return {
"user_id": user.id,
"account": user.account,
"status": "skipped",
"reason": "missing_news_id",
"interactions": [],
"article_title": news_title,
}
# 读取互动概率
comment_prob = float(await self._get_config_from_db(db, "comment_probability", "0.4"))
@@ -317,6 +396,7 @@ class SchedulerService:
forward_prob = float(await self._get_config_from_db(db, "forward_probability", "0.15"))
interactions_done = []
action_failures = []
# ① 先记录阅读(每次必做,模拟真实用户打开文章)
await news_service.read_news(db, user, news_id)
@@ -331,6 +411,8 @@ class SchedulerService:
if success:
interactions_done.append("like")
await self._incr_total(db, user_id)
else:
action_failures.append({"type": "like", "error": err})
# ③ 收藏(每篇文章每用户每天只收藏一次)
if "collect" not in done_on_this and random.random() < collect_prob:
@@ -338,6 +420,8 @@ class SchedulerService:
await self._save_record(db, user, news_id, news_title, "collect", None, 0, success, err)
if success:
interactions_done.append("collect")
else:
action_failures.append({"type": "collect", "error": err})
# ④ 转发(每篇文章每用户每天只转发一次)
if "forward" not in done_on_this and random.random() < forward_prob:
@@ -346,109 +430,433 @@ class SchedulerService:
if success:
interactions_done.append("forward")
await self._incr_total(db, user_id)
else:
action_failures.append({"type": "forward", "error": err})
# ⑤ 评论/回复逻辑(去重:已评论过文章改为回复他人评论
# ⑤ 评论/回复逻辑:评论和回复互相独立,未评论过文章也可以回复他人评论
if can_comment and personality:
style_prompt = personality.comment_style_prompt or ""
safe_word_max = min(personality.word_count_max, 80)
if already_commented_this:
# 已评论过此文章 → 改为回复其他用户的评论(虚拟用户互动)
if random.random() < reply_prob:
existing = await news_service.get_comments(db, user, news_id)
if existing:
# 优先回复虚拟用户的评论(促进互动),过滤掉自己的评论
from app.core.redis_client import get_session as _gs
my_sess = await _gs(user.id)
my_uid = my_sess.get("platform_uid", "") if my_sess else ""
others = [c for c in existing
if str(c.get("userId") or c.get("createUser") or "") != my_uid]
if others:
target = random.choice(others)
cid = str(target.get("id") or target.get("commentId") or "")
parent_content = target.get("content") or ""
if cid:
reply_text, r_tokens = await ai_service.generate_reply(
db, news_title, parent_content,
style_prompt,
personality.word_count_min,
safe_word_max
)
if reply_text:
r_ok, r_err = await news_service.post_reply(
db, user, news_id, cid, reply_text
)
await self._save_record(
db, user, news_id, news_title, "reply",
reply_text, r_tokens, r_ok, r_err,
parent_comment_id=cid
)
if r_ok:
interactions_done.append("reply")
logger.info(f"💬 {user.account} 回复了已评论文章的评论(去重逻辑)")
else:
# 未评论过此文章 → 正常发评论
if random.random() < comment_prob:
comment_text, tokens = await ai_service.generate_comment(
db, news_title, news_content,
style_prompt, personality.word_count_min, safe_word_max
)
if comment_text:
success, err = await news_service.post_comment(
db, user, news_id, news_title, comment_text,
news_author_id=news_author, org_id=article_org_id
)
await self._save_record(
db, user, news_id, news_title, "comment",
comment_text, tokens, success, err
)
if success:
interactions_done.append("comment")
await db.execute(
update(VirtualUser).where(VirtualUser.id == user_id).values(
today_comment_count=VirtualUser.today_comment_count + 1,
total_interactions=VirtualUser.total_interactions + 1,
last_interact_at=datetime.now()
)
)
if random.random() < reply_prob:
reply_actions, reply_failures = await self._run_reply_interaction_chain(
db=db,
starter=user,
starter_personality=personality,
news_service=news_service,
ai_service=ai_service,
news_id=news_id,
news_title=news_title,
article_org_id=article_org_id,
style_prompt=style_prompt,
safe_word_max=safe_word_max,
)
interactions_done.extend(reply_actions)
action_failures.extend(reply_failures)
# ⑥ 评论成功后,随机回复其他用户的评论(互动链)
if random.random() < reply_prob:
existing = await news_service.get_comments(db, user, news_id)
if existing:
from app.core.redis_client import get_session as _gs2
my_sess2 = await _gs2(user.id)
my_uid2 = my_sess2.get("platform_uid", "") if my_sess2 else ""
others2 = [c for c in existing
if str(c.get("userId") or c.get("createUser") or "") != my_uid2]
if others2:
target2 = random.choice(others2)
cid2 = str(target2.get("id") or target2.get("commentId") or "")
parent_content2 = target2.get("content") or ""
if cid2:
reply_text2, r_tokens2 = await ai_service.generate_reply(
db, news_title, parent_content2,
style_prompt,
personality.word_count_min,
safe_word_max
)
if reply_text2:
r_ok2, r_err2 = await news_service.post_reply(
db, user, news_id, cid2, reply_text2
)
await self._save_record(
db, user, news_id, news_title, "reply",
reply_text2, r_tokens2, r_ok2, r_err2,
parent_comment_id=cid2
)
if r_ok2:
interactions_done.append("reply")
# 每篇文章每个用户每天只发一条顶层评论;回复不再要求先评论
if not already_commented_this and random.random() < comment_prob:
comment_text, tokens = await ai_service.generate_comment(
db, news_title, news_content,
style_prompt, personality.word_count_min, safe_word_max
)
if comment_text:
success, err, comment_record_id = await news_service.post_comment_with_record_id(
db, user, news_id, news_title, comment_text,
news_author_id=news_author, org_id=article_org_id
)
await self._save_record(
db, user, news_id, news_title, "comment",
comment_text, tokens, success, err,
platform_record_id=comment_record_id,
)
if success:
interactions_done.append("comment")
await db.execute(
update(VirtualUser).where(VirtualUser.id == user_id).values(
today_comment_count=VirtualUser.today_comment_count + 1,
total_interactions=VirtualUser.total_interactions + 1,
last_interact_at=datetime.now()
)
)
else:
action_failures.append({"type": "comment", "error": err})
await db.commit()
logger.info(f"👤 {user.account} 互动完成: {interactions_done} [新闻: {news_title[:20]}]")
if interactions_done:
return {
"user_id": user.id,
"account": user.account,
"status": "success",
"reason": "",
"interactions": interactions_done,
"article_id": news_id,
"article_title": news_title,
}
if action_failures:
return {
"user_id": user.id,
"account": user.account,
"status": "failed",
"reason": "; ".join(
f"{item['type']}:{item['error'] or 'unknown'}" for item in action_failures
),
"interactions": [],
"article_id": news_id,
"article_title": news_title,
}
return {
"user_id": user.id,
"account": user.account,
"status": "skipped",
"reason": "no_actions_triggered",
"interactions": [],
"article_id": news_id,
"article_title": news_title,
}
except Exception as e:
logger.error(f"用户 {user_id} 互动异常: {e}")
return {
"user_id": user_id,
"account": "",
"status": "failed",
"reason": str(e),
"interactions": [],
}
async def _run_reply_interaction_chain(
self,
db,
starter: VirtualUser,
starter_personality,
news_service,
ai_service,
news_id: str,
news_title: str,
article_org_id: str,
style_prompt: str,
safe_word_max: int,
) -> tuple[list[str], list[dict]]:
"""主动回复评论,并按概率安排后续延迟回应。"""
from app.core.redis_client import get_session
actions: list[str] = []
failures: list[dict] = []
starter_sess = await get_session(starter.id)
starter_uid = str(starter_sess.get("platform_uid") or "") if starter_sess else ""
comments = await news_service.get_comments(db, starter, news_id)
if not comments:
return actions, failures
candidates = [
c for c in comments
if str(c.get("id") or c.get("commentId") or "")
and (c.get("content") or "").strip()
and str(c.get("userId") or c.get("createUser") or "") != starter_uid
]
if not candidates:
return actions, failures
parent_comment = random.choice(candidates)
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
root_content = (parent_comment.get("content") or "").strip()
context = f"准备回复这条评论:{root_content}"
reply_text, reply_tokens = await ai_service.generate_thread_reply(
db, news_title, root_content, context,
style_prompt,
starter_personality.word_count_min,
safe_word_max,
)
if not reply_text:
return actions, failures
ok, err, reply_id = await news_service.post_reply_with_record_id(
db, starter, news_id, parent_comment_id, reply_text,
parent_comment=parent_comment,
article_title=news_title,
org_id=article_org_id,
)
await self._save_record(
db, starter, news_id, news_title, "reply",
reply_text, reply_tokens, ok, err,
parent_comment_id=parent_comment_id,
platform_record_id=reply_id,
)
if not ok:
failures.append({"type": "reply", "error": err})
return actions, failures
actions.append("reply")
await self._incr_total(db, starter.id)
logger.info(f"💬 {starter.account} 回复了文章评论")
starter_reply = {
"id": reply_id,
"content": reply_text,
"createUser": starter_uid,
"fromUserName": starter.real_name or starter.nickname or starter.account,
}
chain_probability = await self._get_float_config(db, "reply_chain_probability", 0.3)
delay_min = await self._get_int_config(db, "reply_chain_delay_min_seconds", 30)
delay_max = await self._get_int_config(db, "reply_chain_delay_max_seconds", 7200)
if delay_max < delay_min:
delay_max = delay_min
# 评论作者如果也是当前系统里的已登录虚拟用户,按概率安排稍后回复这条回复。
parent_author_uid = str(parent_comment.get("createUser") or parent_comment.get("userId") or "")
parent_author = await self._get_logged_in_user_by_platform_uid(db, parent_author_uid)
if (
parent_author
and parent_author.id != starter.id
and random.random() < chain_probability
):
delay_seconds = random.randint(delay_min, delay_max)
await self._enqueue_pending_reply_task(
db=db,
delay_seconds=delay_seconds,
actor_id=parent_author.id,
news_id=news_id,
news_title=news_title,
article_org_id=article_org_id,
parent_comment=parent_comment,
reply_to=starter_reply,
root_content=root_content,
context=f"对方刚回复了你的评论:{reply_text}",
next_actor_id=starter.id,
next_probability=chain_probability,
next_delay_min=delay_min,
next_delay_max=delay_max,
)
logger.info(
f"{parent_author.account} 已进入待发送回复队列,延迟 {delay_seconds}s 后发送"
)
return actions, failures
async def _enqueue_pending_reply_task(
self,
db,
delay_seconds: int,
actor_id: int,
news_id: str,
news_title: str,
article_org_id: str,
parent_comment: dict,
reply_to: dict,
root_content: str,
context: str,
next_actor_id: int | None = None,
next_probability: float = 0.3,
next_delay_min: int = 30,
next_delay_max: int = 7200,
):
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
db.add(PendingReplyTask(
actor_user_id=actor_id,
next_actor_user_id=next_actor_id,
news_id=news_id,
news_title=news_title,
article_org_id=article_org_id,
parent_comment_id=parent_comment_id,
parent_comment=parent_comment,
reply_to=reply_to,
root_content=root_content,
context=context,
next_probability=next_probability,
next_delay_min_seconds=next_delay_min,
next_delay_max_seconds=next_delay_max,
status=0,
attempts=0,
scheduled_at=datetime.now() + timedelta(seconds=max(0, delay_seconds)),
))
async def _process_pending_reply_tasks(self):
from app.services.news_service import news_service
from app.services.ai_service import ai_service
async with AsyncSessionLocal() as db:
try:
now = datetime.now()
await db.execute(
update(PendingReplyTask)
.where(
PendingReplyTask.status == 1,
PendingReplyTask.locked_at < now - timedelta(minutes=10),
)
.values(status=0, last_error="发送中超时,重新入队")
)
result = await db.execute(
select(PendingReplyTask)
.where(
PendingReplyTask.status == 0,
PendingReplyTask.scheduled_at <= now,
)
.order_by(PendingReplyTask.scheduled_at.asc())
.limit(10)
)
tasks = result.scalars().all()
for task in tasks:
await self._process_pending_reply_task(db, task, news_service, ai_service)
await db.commit()
except Exception as e:
await db.rollback()
logger.error(f"待发送回复队列处理异常: {e}")
async def _process_pending_reply_task(self, db, task: PendingReplyTask, news_service, ai_service):
task.status = 1
task.locked_at = datetime.now()
task.attempts = (task.attempts or 0) + 1
await db.flush()
actor = await self._get_user_by_id(db, task.actor_user_id)
if not actor or actor.status != 2 or actor.is_enabled != 1:
task.status = 3
task.last_error = "用户未登录或已禁用"
return
reply_result = await self._post_contextual_reply(
db=db,
actor=actor,
news_service=news_service,
ai_service=ai_service,
news_id=task.news_id,
news_title=task.news_title or "",
article_org_id=task.article_org_id or "",
parent_comment=task.parent_comment or {},
reply_to=task.reply_to or {},
root_content=task.root_content or "",
context=task.context or "",
)
if not reply_result["ok"]:
task.status = 3 if task.attempts >= 3 else 0
task.last_error = reply_result["error"] or "生成或发送回复失败"
if task.status == 0:
task.scheduled_at = datetime.now() + timedelta(minutes=5)
logger.warning(f"待发送回复失败 task_id={task.id} user={actor.account}: {task.last_error}")
return
task.status = 2
task.sent_at = datetime.now()
task.last_error = None
await self._incr_total(db, actor.id)
logger.info(f"💬 {actor.account} 发送了待发送回复 task_id={task.id}")
if task.next_actor_user_id and random.random() < float(task.next_probability or 0.3):
delay_min = int(task.next_delay_min_seconds or 30)
delay_max = max(delay_min, int(task.next_delay_max_seconds or 7200))
next_delay = random.randint(delay_min, delay_max)
await self._enqueue_pending_reply_task(
db=db,
delay_seconds=next_delay,
actor_id=task.next_actor_user_id,
news_id=task.news_id,
news_title=task.news_title or "",
article_org_id=task.article_org_id or "",
parent_comment=task.parent_comment or {},
reply_to=reply_result["reply"],
root_content=task.root_content or "",
context=(
f"原评论:{task.root_content or ''}\n"
f"上一条回复:{(task.reply_to or {}).get('content') or ''}\n"
f"对方回应:{reply_result['content']}"
),
next_actor_id=None,
next_probability=float(task.next_probability or 0.3),
next_delay_min=delay_min,
next_delay_max=delay_max,
)
logger.info(f"⏳ 已入队继续回复,延迟 {next_delay}s 后发送")
async def _post_contextual_reply(
self,
db,
actor: VirtualUser,
news_service,
ai_service,
news_id: str,
news_title: str,
article_org_id: str,
parent_comment: dict,
reply_to: dict,
root_content: str,
context: str,
) -> dict:
personality = await self._get_user_personality(db, actor.id)
style_prompt = personality.comment_style_prompt if personality else ""
word_min = personality.word_count_min if personality else 15
word_max = min(personality.word_count_max, 80) if personality else 60
content, tokens = await ai_service.generate_thread_reply(
db, news_title, root_content, context,
style_prompt, word_min, word_max,
)
if not content:
return {"ok": False, "error": "", "reply": {}, "content": ""}
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
ok, err, reply_id = await news_service.post_reply_with_record_id(
db, actor, news_id, parent_comment_id, content,
parent_comment=parent_comment,
reply_to=reply_to,
article_title=news_title,
org_id=article_org_id,
)
await self._save_record(
db, actor, news_id, news_title, "reply",
content, tokens, ok, err,
parent_comment_id=parent_comment_id,
platform_record_id=reply_id,
)
from app.core.redis_client import get_session
sess = await get_session(actor.id)
actor_uid = str(sess.get("platform_uid") or "") if sess else ""
return {
"ok": ok,
"error": "" if ok else err,
"content": content,
"reply": {
"id": reply_id,
"content": content,
"createUser": actor_uid,
"fromUserName": actor.real_name or actor.nickname or actor.account,
},
}
async def _get_logged_in_user_by_platform_uid(self, db, platform_uid: str) -> VirtualUser | None:
if not platform_uid:
return None
result = await db.execute(
select(VirtualUser).where(
VirtualUser.platform_uid == platform_uid,
VirtualUser.status == 2,
VirtualUser.is_enabled == 1,
)
)
return result.scalar_one_or_none()
async def _get_user_by_id(self, db, user_id: int) -> VirtualUser | None:
result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
return result.scalar_one_or_none()
async def _get_user_personality(self, db, user_id: int):
result = await db.execute(
select(UserPersonality).where(UserPersonality.user_id == user_id)
)
return result.scalar_one_or_none()
async def _get_float_config(self, db, key: str, default: float) -> float:
try:
return float(await self._get_config_from_db(db, key, str(default)))
except (TypeError, ValueError):
return default
async def _get_int_config(self, db, key: str, default: int) -> int:
try:
return int(float(await self._get_config_from_db(db, key, str(default))))
except (TypeError, ValueError):
return default
async def _incr_total(self, db, user_id: int):
await db.execute(
@@ -511,4 +919,4 @@ class SchedulerService:
logger.info("每日计数重置完成")
scheduler_service = SchedulerService()
scheduler_service = SchedulerService()

View File

@@ -230,7 +230,7 @@ class StatsService:
"comment": "评论", "reply": "回复", "like": "点赞",
"collect": "收藏", "forward": "转发"
}
STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败"}
STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败", 3: "已取消"}
items = []
for r in records: