Files
huihuiSquare/backend/app/services/scheduler.py
2026-07-24 14:04:21 +08:00

923 lines
40 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""调度服务 - 定时自动互动、会话校验"""
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()