923 lines
40 KiB
Python
Executable File
923 lines
40 KiB
Python
Executable File
"""调度服务 - 定时自动互动、会话校验"""
|
||
import random
|
||
import asyncio
|
||
from datetime import datetime, date, timedelta
|
||
from typing import Optional
|
||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||
from apscheduler.triggers.interval import IntervalTrigger
|
||
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, PendingReplyTask, SystemConfig
|
||
|
||
|
||
class SchedulerService:
|
||
def __init__(self):
|
||
self.scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||
self._running = False
|
||
|
||
async def run_once_now(self, db=None):
|
||
"""立即执行一次互动,不受时间段限制"""
|
||
from sqlalchemy import select
|
||
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,
|
||
VirtualUser.is_enabled == 1,
|
||
)
|
||
)
|
||
users = result_r.scalars().all()
|
||
if not users:
|
||
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(max_concurrent, len(users)))
|
||
import asyncio
|
||
tasks = [self._execute_user_interaction(u.id) 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:
|
||
return
|
||
# 会话校验:每10分钟
|
||
self.scheduler.add_job(
|
||
self._check_sessions, IntervalTrigger(minutes=10),
|
||
id="check_sessions", replace_existing=True
|
||
)
|
||
# 互动任务:每5分钟检查一次(内部判断是否在活跃时间段)
|
||
self.scheduler.add_job(
|
||
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
|
||
id="daily_reset", replace_existing=True
|
||
)
|
||
self.scheduler.start()
|
||
self._running = True
|
||
logger.info("调度器已启动")
|
||
# 记录启动时间
|
||
async with AsyncSessionLocal() as db:
|
||
await self._set_config(db, "system_start_time", datetime.now().isoformat())
|
||
|
||
async def stop(self):
|
||
if self.scheduler.running:
|
||
self.scheduler.shutdown(wait=False)
|
||
self._running = False
|
||
|
||
async def _get_config(self, db, key: str, default=None):
|
||
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == key))
|
||
cfg = result.scalar_one_or_none()
|
||
return cfg.config_value if cfg else default
|
||
|
||
async def _set_config(self, db, key: str, value: str):
|
||
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == key))
|
||
cfg = result.scalar_one_or_none()
|
||
if cfg:
|
||
cfg.config_value = value
|
||
else:
|
||
db.add(SystemConfig(config_key=key, config_value=value))
|
||
await db.commit()
|
||
|
||
async def _check_sessions(self):
|
||
"""定时校验登录状态"""
|
||
from app.services.news_service import news_service
|
||
async with AsyncSessionLocal() as db:
|
||
result = await db.execute(
|
||
select(VirtualUser).where(VirtualUser.status == 2, VirtualUser.is_enabled == 1)
|
||
)
|
||
users = result.scalars().all()
|
||
for user in users:
|
||
try:
|
||
valid = await news_service.check_session(db, user)
|
||
if not valid:
|
||
logger.warning(f"用户 {user.account} 会话失效,尝试重登")
|
||
await news_service.login(db, user)
|
||
except Exception as e:
|
||
logger.error(f"会话校验异常 {user.account}: {e}")
|
||
|
||
async def _run_interactions(self):
|
||
"""执行互动任务"""
|
||
async with AsyncSessionLocal() as db:
|
||
# 检查调度器开关
|
||
enabled = await self._get_config(db, "scheduler_enabled", "true")
|
||
if enabled != "true":
|
||
return
|
||
|
||
# 检查Token限额
|
||
token_limited = await self._get_config(db, "token_limit_reached", "false")
|
||
if token_limited == "true":
|
||
return
|
||
|
||
# 检查互动时间段(北京时间 UTC+8)
|
||
from datetime import timezone, timedelta
|
||
tz_beijing = timezone(timedelta(hours=8))
|
||
now_bj = datetime.now(tz_beijing)
|
||
now_time = now_bj.strftime("%H:%M")
|
||
start_str = await self._get_config(db, "interact_time_start", "08:00")
|
||
end_str = await self._get_config(db, "interact_time_end", "22:00")
|
||
if not (start_str <= now_time <= end_str):
|
||
logger.debug(f"[调度] 当前北京时间 {now_time} 不在互动时段 {start_str}-{end_str}")
|
||
return
|
||
|
||
# 获取最小互动间隔(秒)
|
||
min_interval = int(await self._get_config(db, "interact_min_interval", "300"))
|
||
|
||
# 获取最大并发
|
||
max_concurrent = int(await self._get_config(db, "max_concurrent_users", "5"))
|
||
|
||
# 获取所有已登录、启用的用户(不加 LIMIT,确保所有用户公平参与)
|
||
result = await db.execute(
|
||
select(VirtualUser).where(
|
||
VirtualUser.status == 2,
|
||
VirtualUser.is_enabled == 1,
|
||
)
|
||
)
|
||
all_users = result.scalars().all()
|
||
|
||
# 没有已登录用户时,尝试登录未登录用户
|
||
if not all_users:
|
||
await self._try_login_users(db)
|
||
return
|
||
|
||
# 检查互动间隔:过滤掉最近 min_interval 秒内已互动的用户
|
||
now_dt = datetime.now()
|
||
eligible = []
|
||
for u in all_users:
|
||
if u.last_interact_at is None:
|
||
eligible.append(u)
|
||
else:
|
||
elapsed = (now_dt - u.last_interact_at).total_seconds()
|
||
if elapsed >= min_interval:
|
||
eligible.append(u)
|
||
|
||
if not eligible:
|
||
logger.debug(f"[调度] 所有 {len(all_users)} 个用户在 {min_interval}s 内已互动,跳过本次")
|
||
return
|
||
|
||
# 按最后互动时间升序排序:最久没互动的用户优先
|
||
eligible.sort(key=lambda u: u.last_interact_at or datetime.min)
|
||
|
||
# 从符合条件的用户中随机选取 max_concurrent 个执行(保证公平轮转)
|
||
batch_size = max_concurrent if max_concurrent > 0 else len(eligible)
|
||
# 优先选最久未互动的用户(前1/3),其余随机补充
|
||
priority_size = max(1, batch_size // 3)
|
||
priority_users = eligible[:priority_size]
|
||
rest_users = eligible[priority_size:]
|
||
random.shuffle(rest_users)
|
||
selected = priority_users + rest_users[:max(0, batch_size - priority_size)]
|
||
|
||
# ── 今日文章配额计算 ──────────────────────────────────────
|
||
# 获取今日文章数量,决定本轮有多少用户应互动今日文章
|
||
today_count = 0
|
||
try:
|
||
from app.services.news_service import news_service as _ns
|
||
today_count = await _ns.count_today_articles(db, selected[0] if selected else None)
|
||
except Exception:
|
||
pass
|
||
|
||
# 配额规则:每篇今日文章最多吸引 3 个虚拟用户,超出部分走历史
|
||
today_quota = min(today_count * 3, len(selected))
|
||
|
||
logger.info(
|
||
f"[调度] 共 {len(all_users)} 个用户,{len(eligible)} 个满足间隔,"
|
||
f"本轮选取 {len(selected)} 个,今日文章 {today_count} 篇,"
|
||
f"配额 {today_quota} 人互动今日/{len(selected)-today_quota} 人走历史"
|
||
)
|
||
|
||
for i, user in enumerate(selected):
|
||
# 超出今日配额的用户强制走历史文章
|
||
force_history = (i >= today_quota)
|
||
asyncio.create_task(self._execute_user_interaction(user.id, force_history=force_history))
|
||
|
||
async def _try_login_users(self, db):
|
||
"""尝试登录未登录的用户"""
|
||
from app.services.news_service import news_service
|
||
result = await db.execute(
|
||
select(VirtualUser).where(
|
||
VirtualUser.status.in_([0, 3]),
|
||
VirtualUser.is_enabled == 1
|
||
).limit(3)
|
||
)
|
||
users = result.scalars().all()
|
||
for user in users:
|
||
try:
|
||
await news_service.login(db, user)
|
||
await asyncio.sleep(2)
|
||
except Exception as e:
|
||
logger.error(f"自动登录失败 {user.account}: {e}")
|
||
|
||
async def _execute_user_interaction(self, user_id: int, force_history: bool = False):
|
||
"""执行单用户互动 - 基于真实接口"""
|
||
from app.services.news_service import news_service
|
||
from app.services.ai_service import ai_service
|
||
|
||
async with AsyncSessionLocal() as db:
|
||
try:
|
||
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 {
|
||
"user_id": user_id,
|
||
"account": getattr(user, "account", ""),
|
||
"status": "skipped",
|
||
"reason": "user_not_logged_in",
|
||
"interactions": [],
|
||
}
|
||
|
||
# 检查今日评论限额
|
||
can_comment = True
|
||
if user.today_comment_count >= user.daily_comment_limit:
|
||
can_comment = False
|
||
logger.info(f'用户 ' + user.account + ' 今日评论已达上限,仍执行点赞/收藏/转发')
|
||
|
||
# 获取人格
|
||
from app.models import UserPersonality
|
||
p_result = await db.execute(
|
||
select(UserPersonality).where(UserPersonality.user_id == user_id)
|
||
)
|
||
personality = p_result.scalar_one_or_none()
|
||
interest_tags = personality.interest_tags if personality else []
|
||
|
||
# 获取新闻列表(基于接口 GET /news/list)
|
||
articles = await news_service.get_news_list(
|
||
db, user, count=5, interest_tags=interest_tags, force_history=force_history
|
||
)
|
||
if not articles:
|
||
# 尝试从 session 获取 org_id 再试一次
|
||
from app.core.redis_client import get_session as _get_sess
|
||
sess = await _get_sess(user.id)
|
||
org_from_sess = sess.get("org_id", "") if sess else ""
|
||
if org_from_sess:
|
||
articles = await news_service.get_news_list(
|
||
db, user, count=5, interest_tags=interest_tags
|
||
)
|
||
if not articles:
|
||
logger.warning(
|
||
f"用户 {user.account} 获取新闻列表为空 "
|
||
f"(orgId={await news_service._cfg(db, 'platform_org_id', '')})"
|
||
)
|
||
return {
|
||
"user_id": user.id,
|
||
"account": user.account,
|
||
"status": "skipped",
|
||
"reason": "no_articles",
|
||
"interactions": [],
|
||
}
|
||
|
||
# ── 文章去重 + 热度加权选取 ─────────────────────────────────
|
||
# 查询今日已互动过的文章(所有类型),避免重复互动同一篇
|
||
from sqlalchemy import func as _func
|
||
from datetime import date as _date
|
||
today_str = datetime.now().date()
|
||
dup_result = await db.execute(
|
||
select(
|
||
InteractionRecord.article_id,
|
||
InteractionRecord.interact_type,
|
||
).where(
|
||
InteractionRecord.user_id == user_id,
|
||
InteractionRecord.status == 1,
|
||
_func.date(InteractionRecord.executed_at) == today_str,
|
||
)
|
||
)
|
||
# {article_id: set of interact_types already done today}
|
||
today_done: dict = {}
|
||
for r in dup_result.all():
|
||
today_done.setdefault(r[0], set()).add(r[1])
|
||
already_commented = {aid for aid, types in today_done.items() if "comment" in types}
|
||
|
||
# 按热度加权:commentNum + praiseNum + readNum 越高权重越大
|
||
# 同时优先未评论过的文章
|
||
def _article_weight(a):
|
||
aid = str(a.get("recordId") or a.get("id", ""))
|
||
base = (
|
||
int(a.get("commentNum") or 0) * 3 +
|
||
int(a.get("praiseNum") or 0) * 2 +
|
||
int(a.get("readNum") or 0)
|
||
)
|
||
# 已评论的文章权重大幅降低(但不为0,还可以点赞/收藏)
|
||
penalty = 0.1 if aid in already_commented else 1.0
|
||
return max(1, base) * penalty
|
||
|
||
weights = [_article_weight(a) for a in articles]
|
||
article = random.choices(articles, weights=weights, k=1)[0]
|
||
|
||
# 判断是否已评论此文章(用于后续逻辑)
|
||
news_id = str(article.get("recordId") or article.get("id", ""))
|
||
already_commented_this = "comment" in today_done.get(news_id, set())
|
||
|
||
# 接口返回字段: id/newsTitle/content/digest/createUser
|
||
# 广场接口字段:recordId=新闻实际ID, id=广场记录ID, title=标题
|
||
news_title = article.get("title") or article.get("newsTitle") or "未知文章"
|
||
news_content = article.get("content") or article.get("digest") or news_title
|
||
news_author = str(article.get("createUser") or "")
|
||
# 从广场数据中顺带获取 orgId
|
||
article_org_id = str(article.get("orgId") or "")
|
||
|
||
if not news_id:
|
||
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"))
|
||
reply_prob = float(await self._get_config_from_db(db, "reply_probability", "0.2"))
|
||
like_prob = float(await self._get_config_from_db(db, "like_probability", "0.6"))
|
||
collect_prob = float(await self._get_config_from_db(db, "collect_probability", "0.3"))
|
||
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)
|
||
|
||
# 今日已对此文章做过的互动类型
|
||
done_on_this = today_done.get(news_id, set())
|
||
|
||
# ② 点赞(每篇文章每用户每天只点赞一次)
|
||
if "like" not in done_on_this and random.random() < like_prob:
|
||
success, err = await news_service.like_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
|
||
await self._save_record(db, user, news_id, news_title, "like", None, 0, success, err)
|
||
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:
|
||
success, err = await news_service.collect_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
|
||
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:
|
||
success, err = await news_service.forward_news(db, user, news_id)
|
||
await self._save_record(db, user, news_id, news_title, "forward", None, 0, success, err)
|
||
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 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 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(
|
||
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||
total_interactions=VirtualUser.total_interactions + 1,
|
||
last_interact_at=datetime.now()
|
||
)
|
||
)
|
||
|
||
async def _save_record(
|
||
self, db, user: VirtualUser, article_id: str, article_title: str,
|
||
interact_type: str, content: Optional[str], tokens: int,
|
||
success: bool, error_msg: str, parent_comment_id: str = None,
|
||
platform_record_id: str = None
|
||
):
|
||
from app.core.redis_client import get_session
|
||
session = await get_session(user.id)
|
||
session_id = session.get("session_id") if session else None
|
||
|
||
record = InteractionRecord(
|
||
user_id=user.id,
|
||
user_nickname=user.nickname,
|
||
user_account=user.account,
|
||
article_id=article_id,
|
||
article_title=article_title,
|
||
interact_type=interact_type,
|
||
content=content,
|
||
parent_comment_id=parent_comment_id,
|
||
platform_record_id=platform_record_id,
|
||
session_id=session_id,
|
||
token_consumed=tokens,
|
||
status=1 if success else 2,
|
||
error_msg=error_msg or None,
|
||
executed_at=datetime.now(),
|
||
)
|
||
db.add(record)
|
||
|
||
async def _get_config_from_db(self, db, key: str, default: str = "") -> str:
|
||
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == key))
|
||
cfg = result.scalar_one_or_none()
|
||
return cfg.config_value if cfg else default
|
||
|
||
async def _daily_reset(self):
|
||
"""每日零点重置计数"""
|
||
async with AsyncSessionLocal() as db:
|
||
await db.execute(
|
||
update(VirtualUser).values(
|
||
today_comment_count=0,
|
||
today_like_count=0
|
||
)
|
||
)
|
||
# 重置Token限额标志
|
||
result = await db.execute(
|
||
select(SystemConfig).where(SystemConfig.config_key == "token_limit_reached")
|
||
)
|
||
cfg = result.scalar_one_or_none()
|
||
if cfg:
|
||
cfg.config_value = "false"
|
||
await db.commit()
|
||
logger.info("每日计数重置完成")
|
||
|
||
|
||
scheduler_service = SchedulerService()
|