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

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