feat: AI虚拟用户新闻互动系统 v1.3.0 初始提交

- 虚拟用户管理(昵称/头像/性别/简介/邮箱同步到目标平台)
- AI互动调度(点赞/收藏/评论/转发)
- 日志时间改为北京时间
- 评论达上限后继续执行点赞收藏转发
- 一键登出全部功能
- 浅色主题UI
This commit is contained in:
stefanfeng
2026-03-31 10:20:57 +08:00
commit 0cfc9bf9c8
53 changed files with 8457 additions and 0 deletions

View File

@@ -0,0 +1,251 @@
"""数据统计服务"""
from datetime import datetime, date, timedelta, timezone
def _fmt_dt(dt):
"""统一输出 UTC 时间,带时区标识,让前端正确解析为 +8"""
if dt is None:
return None
if dt.tzinfo is None:
# 数据库存的是 UTC补上时区信息
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from app.models import VirtualUser, InteractionRecord, TokenStat, SystemConfig
from app.core.logger import logger
class StatsService:
async def get_dashboard(self, db: AsyncSession) -> dict:
"""获取控制台数据"""
today = date.today()
now = datetime.now()
month_start = today.replace(day=1)
# 用户统计
user_stats = await self._get_user_stats(db)
# 今日互动统计
today_stats = await self._get_today_stats(db, today)
# 本月互动统计
monthly_stats = await self._get_monthly_stats(db, month_start, today)
# Token统计
token_stats = await self._get_token_stats(db, today)
# 系统状态
system_status = await self._get_system_status(db, now)
# 在线用户数
online_count_result = await db.execute(
select(func.count()).where(VirtualUser.status == 2)
)
online_count = online_count_result.scalar() or 0
return {
"user_stats": user_stats,
"today_interactions": today_stats,
"monthly_stats": monthly_stats,
"token_stats": token_stats,
"system_status": system_status,
"online_users": online_count,
}
async def _get_user_stats(self, db: AsyncSession) -> dict:
total = await db.execute(select(func.count()).select_from(VirtualUser))
normal = await db.execute(select(func.count()).where(VirtualUser.is_enabled == 1))
banned = await db.execute(select(func.count()).where(VirtualUser.status == 4))
abnormal = await db.execute(select(func.count()).where(VirtualUser.status == 3))
return {
"total": total.scalar() or 0,
"normal": normal.scalar() or 0,
"banned": banned.scalar() or 0,
"abnormal": abnormal.scalar() or 0,
}
async def _get_today_stats(self, db: AsyncSession, today: date) -> dict:
result = await db.execute(
select(
InteractionRecord.interact_type,
func.count().label("cnt"),
).where(
func.date(InteractionRecord.executed_at) == today,
InteractionRecord.status == 1,
).group_by(InteractionRecord.interact_type)
)
rows = result.all()
stats = {"comment": 0, "reply": 0, "like": 0, "collect": 0, "forward": 0, "total": 0}
for row in rows:
if row.interact_type in stats:
stats[row.interact_type] = row.cnt
stats["total"] += row.cnt
return stats
async def _get_monthly_stats(self, db: AsyncSession, month_start: date, today: date) -> dict:
result = await db.execute(
select(func.count()).where(
InteractionRecord.executed_at >= month_start,
InteractionRecord.status == 1,
)
)
return {"total": result.scalar() or 0, "month_start": month_start.isoformat()}
async def _get_token_stats(self, db: AsyncSession, today: date) -> dict:
# 今日
today_stat = await db.execute(select(TokenStat).where(TokenStat.stat_date == today))
today_row = today_stat.scalar_one_or_none()
# 每日限额
limit_cfg = await db.execute(
select(SystemConfig).where(SystemConfig.config_key == "daily_token_limit")
)
limit_row = limit_cfg.scalar_one_or_none()
daily_limit = int(limit_row.config_value) if limit_row else 100000
today_used = today_row.total_tokens if today_row else 0
return {
"today_used": today_used,
"daily_limit": daily_limit,
"remaining": max(0, daily_limit - today_used),
"today_calls": today_row.call_count if today_row else 0,
}
async def _get_system_status(self, db: AsyncSession, now: datetime) -> dict:
start_cfg = await db.execute(
select(SystemConfig).where(SystemConfig.config_key == "system_start_time")
)
start_row = start_cfg.scalar_one_or_none()
uptime = ""
if start_row and start_row.config_value:
try:
start_time = datetime.fromisoformat(start_row.config_value)
delta = now - start_time
hours, rem = divmod(int(delta.total_seconds()), 3600)
mins = rem // 60
uptime = f"{hours}小时{mins}分钟"
except Exception:
uptime = "未知"
scheduler_cfg = await db.execute(
select(SystemConfig).where(SystemConfig.config_key == "scheduler_enabled")
)
scheduler_row = scheduler_cfg.scalar_one_or_none()
return {
"uptime": uptime,
"scheduler_enabled": (scheduler_row.config_value == "true") if scheduler_row else True,
"current_time": now.isoformat(),
}
async def get_token_trend(self, db: AsyncSession, days: int = 30) -> list:
"""Token消耗趋势近N天"""
end_date = date.today()
start_date = end_date - timedelta(days=days - 1)
result = await db.execute(
select(TokenStat).where(
TokenStat.stat_date >= start_date,
TokenStat.stat_date <= end_date,
).order_by(TokenStat.stat_date)
)
rows = result.scalars().all()
stat_map = {r.stat_date.isoformat(): r.total_tokens for r in rows}
trend = []
for i in range(days):
d = (start_date + timedelta(days=i)).isoformat()
trend.append({"date": d, "tokens": stat_map.get(d, 0)})
return trend
async def get_monthly_token_trend(self, db: AsyncSession) -> list:
"""近12个月Token消耗"""
today = date.today()
months = []
for i in range(11, -1, -1):
if today.month - i <= 0:
year = today.year - 1
month = today.month - i + 12
else:
year = today.year
month = today.month - i
months.append((year, month))
trend = []
for year, month in months:
start = date(year, month, 1)
if month == 12:
end = date(year + 1, 1, 1) - timedelta(days=1)
else:
end = date(year, month + 1, 1) - timedelta(days=1)
result = await db.execute(
select(func.sum(TokenStat.total_tokens)).where(
TokenStat.stat_date >= start, TokenStat.stat_date <= end
)
)
total = result.scalar() or 0
trend.append({"month": f"{year}-{month:02d}", "tokens": total})
return trend
async def get_interaction_records(
self, db: AsyncSession,
page: int = 1, page_size: int = 20,
user_id: int = None, interact_type: str = None,
status: int = None, start_date: str = None,
end_date: str = None, keyword: str = None
) -> dict:
query = select(InteractionRecord)
conditions = []
if user_id:
conditions.append(InteractionRecord.user_id == user_id)
if interact_type:
conditions.append(InteractionRecord.interact_type == interact_type)
if status is not None:
conditions.append(InteractionRecord.status == status)
if start_date:
conditions.append(InteractionRecord.executed_at >= start_date)
if end_date:
conditions.append(InteractionRecord.executed_at <= end_date + " 23:59:59")
if keyword:
from sqlalchemy import or_
conditions.append(
or_(InteractionRecord.article_title.like(f"%{keyword}%"),
InteractionRecord.content.like(f"%{keyword}%"),
InteractionRecord.user_nickname.like(f"%{keyword}%"))
)
if conditions:
query = query.where(and_(*conditions))
count_q = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_q)).scalar()
query = query.order_by(InteractionRecord.executed_at.desc()).offset(
(page - 1) * page_size
).limit(page_size)
result = await db.execute(query)
records = result.scalars().all()
INTERACT_LABELS = {
"comment": "评论", "reply": "回复", "like": "点赞",
"collect": "收藏", "forward": "转发"
}
STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败"}
items = []
for r in records:
items.append({
"id": r.id, "user_id": r.user_id,
"user_nickname": r.user_nickname, "user_account": r.user_account,
"article_id": r.article_id, "article_title": r.article_title,
"interact_type": r.interact_type,
"interact_type_label": INTERACT_LABELS.get(r.interact_type, r.interact_type),
"content": r.content, "token_consumed": r.token_consumed,
"status": r.status, "status_label": STATUS_LABELS.get(r.status, "未知"),
"error_msg": r.error_msg, "retry_count": r.retry_count,
"executed_at": _fmt_dt(r.executed_at),
})
return {"total": total, "page": page, "page_size": page_size, "items": items}
stats_service = StatsService()