1.0.0初始化源代码

This commit is contained in:
yuqianqian10204095yu
2026-03-23 15:40:36 +08:00
parent f13ecb3bba
commit cebc0a288f
53 changed files with 5300 additions and 0 deletions

55
backend/.env.example Normal file
View File

@@ -0,0 +1,55 @@
# 应用基础配置
APP_NAME=会会虚拟用户 AI 互动系统
APP_VERSION=1.0.0
DEBUG=False
API_PREFIX=/api/v1
# 数据库配置
DATABASE_HOST=mysql
DATABASE_PORT=3306
DATABASE_USER=root
DATABASE_PASSWORD=root123456
DATABASE_NAME=huihui_ai_bot
# 或者使用完整的 DATABASE_URL
# DATABASE_URL=mysql+pymysql://root:root123456@mysql:3306/huihui_ai_bot?charset=utf8mb4
# JWT 配置(生产环境请修改)
JWT_SECRET_KEY=your-secret-key-change-in-production-abc123xyz
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=10080
# 会会接口配置
HUIHUI_API_BASE=http://192.168.1.200:63120
HUIHUI_DOC_URL=http://192.168.1.200:63120/doc.html
# AI 模型配置
DEFAULT_AI_MODEL=openai
# OpenAI 配置
OPENAI_API_KEY=sk-your-openai-api-key
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-3.5-turbo
# 智谱 AI 配置
ZHIPU_API_KEY=your-zhipu-api-key
ZHIPU_MODEL=glm-4
# 系统限制配置
MAX_TOKENS_PER_DAY=10000
MAX_COMMENTS_PER_USER_PER_DAY=20
MAX_REPLIES_PER_USER_PER_DAY=10
# 定时任务配置
TASK_START_HOUR=9
TASK_END_HOUR=22
TASK_INTERVAL_MIN=10
TASK_INTERVAL_MAX=30
# 互动概率配置
LIKE_PROBABILITY=0.8
FAVORITE_PROBABILITY=0.5
SHARE_PROBABILITY=0.3
# 文件存储配置
UPLOAD_DIR=/app/data/uploads
LOG_DIR=/app/data/logs

39
backend/Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
FROM python:3.11-slim
# 设置工作目录
WORKDIR /app
# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# 安装系统依赖
RUN apt-get update && apt-get install -y \
gcc \
default-libmysqlclient-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件
COPY requirements.txt .
# 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码
COPY . .
# 创建数据目录
RUN mkdir -p /app/data/uploads /app/data/logs
# 暴露端口
EXPOSE 8000
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8000/health')"
# 启动命令
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,3 @@
"""
API 路由模块初始化
"""

136
backend/app/api/ai_model.py Normal file
View File

@@ -0,0 +1,136 @@
"""
AI 模型配置 API
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.models.base import get_db
from app.models.ai_model import AIModelConfig
from app.schemas.ai_model import (
AIModelConfigCreate,
AIModelConfigUpdate,
AIModelConfigResponse,
AIModelTestRequest,
AIModelTestResponse
)
from app.services.ai_service import ai_service
router = APIRouter()
@router.get("", response_model=List[AIModelConfigResponse])
def get_ai_models(
db: Session = Depends(get_db)
):
"""获取所有 AI 模型配置"""
models = db.query(AIModelConfig).all()
return models
@router.get("/{model_id}", response_model=AIModelConfigResponse)
def get_ai_model(
model_id: int,
db: Session = Depends(get_db)
):
"""获取 AI 模型详情"""
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model not found")
return model
@router.post("", response_model=AIModelConfigResponse)
def create_ai_model(
model_data: AIModelConfigCreate,
db: Session = Depends(get_db)
):
"""创建 AI 模型配置"""
# 检查是否已存在
existing = db.query(AIModelConfig).filter(
AIModelConfig.model_name == model_data.model_name
).first()
if existing:
raise HTTPException(status_code=400, detail="Model already exists")
model = AIModelConfig(**model_data.model_dump())
# 如果是第一个模型,设为默认
if not db.query(AIModelConfig).filter(AIModelConfig.is_default == True).first():
model.is_default = True
db.add(model)
db.commit()
db.refresh(model)
return model
@router.put("/{model_id}", response_model=AIModelConfigResponse)
def update_ai_model(
model_id: int,
model_data: AIModelConfigUpdate,
db: Session = Depends(get_db)
):
"""更新 AI 模型配置"""
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model not found")
update_data = model_data.model_dump(exclude_unset=True)
# 如果设置默认模型,先取消其他模型的默认状态
if update_data.get("is_default"):
db.query(AIModelConfig).update({"is_default": False})
for key, value in update_data.items():
setattr(model, key, value)
db.commit()
db.refresh(model)
return model
@router.delete("/{model_id}")
def delete_ai_model(
model_id: int,
db: Session = Depends(get_db)
):
"""删除 AI 模型配置"""
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model not found")
db.delete(model)
db.commit()
return {"message": "Model deleted successfully"}
@router.post("/test", response_model=AIModelTestResponse)
async def test_ai_model(
request: AIModelTestRequest,
db: Session = Depends(get_db)
):
"""测试 AI 模型"""
model = db.query(AIModelConfig).filter(AIModelConfig.id == request.model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model not found")
model_config = {
"provider": model.provider,
"model_name": model.model_name,
"api_key": model.api_key,
"api_url": model.api_url,
"temperature": model.temperature,
"max_tokens": model.max_tokens,
}
result = await ai_service.test_model(
model_config=model_config,
test_prompt=request.test_prompt
)
return result

View File

@@ -0,0 +1,150 @@
"""
控制台 API
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from typing import List
from datetime import date, timedelta
from app.models.base import get_db
from app.schemas.dashboard import DashboardStats, CoreStats, DailyUsageItem, MonthlyUsageItem
from app.services.token_service import TokenService, get_token_service
from app.services.virtual_user_service import VirtualUserService, get_virtual_user_service
from app.models.virtual_user import VirtualUser, UserStatus
from app.models.interaction import InteractionRecord, InteractionType, InteractionStatus
from app.models.token_usage import TokenUsage
from sqlalchemy import func, and_
router = APIRouter()
@router.get("", response_model=DashboardStats)
def get_dashboard_stats(
db: Session = Depends(get_db),
token_service: TokenService = Depends(get_token_service),
user_service: VirtualUserService = Depends(get_virtual_user_service)
):
"""获取控制台统计数据"""
# 核心指标统计
today = date.today()
yesterday = today - timedelta(days=1)
# 用户统计
total_users = db.query(VirtualUser).count()
active_users = db.query(VirtualUser).filter(VirtualUser.status == UserStatus.ACTIVE).count()
disabled_users = total_users - active_users
# 今日互动统计
today_interactions = db.query(InteractionRecord).filter(
and_(
func.date(InteractionRecord.execution_time) == today,
InteractionRecord.status == InteractionStatus.SUCCESS
)
).all()
today_comments = sum(1 for i in today_interactions if i.interaction_type == InteractionType.COMMENT)
today_replies = sum(1 for i in today_interactions if i.interaction_type == InteractionType.REPLY)
today_likes = sum(1 for i in today_interactions if i.interaction_type == InteractionType.LIKE)
today_favorites = sum(1 for i in today_interactions if i.interaction_type == InteractionType.FAVORITE)
today_shares = sum(1 for i in today_interactions if i.interaction_type == InteractionType.SHARE)
# 昨日互动统计
yesterday_interactions = db.query(InteractionRecord).filter(
and_(
func.date(InteractionRecord.execution_time) == yesterday,
InteractionRecord.status == InteractionStatus.SUCCESS
)
).all()
yesterday_comments = sum(1 for i in yesterday_interactions if i.interaction_type == InteractionType.COMMENT)
yesterday_replies = sum(1 for i in yesterday_interactions if i.interaction_type == InteractionType.REPLY)
# Token 统计
today_tokens = token_service.get_today_usage()
month_tokens = token_service.get_month_usage()
remaining_tokens = token_service.get_remaining_tokens()
core_stats = CoreStats(
total_users=total_users,
active_users=active_users,
disabled_users=disabled_users,
today_comments=today_comments,
today_replies=today_replies,
today_likes=today_likes,
today_favorites=today_favorites,
today_shares=today_shares,
yesterday_comments=yesterday_comments,
yesterday_replies=yesterday_replies,
month_tokens=month_tokens,
today_tokens=today_tokens,
remaining_tokens=remaining_tokens
)
# 每日 Token 使用(近 30 天)
daily_usages = token_service.get_daily_usages(days=30)
daily_items = [DailyUsageItem(date=u["date"], tokens=u["tokens"], comments=0, replies=0) for u in daily_usages]
# 每月 Token 使用(近 12 个月)
monthly_usages = token_service.get_monthly_usages(months=12)
monthly_items = [MonthlyUsageItem(month=u["month"], tokens=u["tokens"]) for u in monthly_usages]
# 最近互动记录
recent_interactions = db.query(InteractionRecord).order_by(
InteractionRecord.execution_time.desc()
).limit(10).all()
return DashboardStats(
core_stats=core_stats,
daily_token_usages=daily_items,
monthly_token_usages=monthly_items,
recent_interactions=[
{
"id": r.id,
"virtual_user_id": r.virtual_user_id,
"interaction_type": r.interaction_type.value,
"status": r.status.value,
"execution_time": r.execution_time
}
for r in recent_interactions
]
)
@router.get("/token/stats")
def get_token_stats(
db: Session = Depends(get_db),
token_service: TokenService = Depends(get_token_service)
):
"""获取 Token 统计"""
today_used = token_service.get_today_usage()
today_limit = 10000 # TODO: 从系统配置读取
return {
"today_used": today_used,
"today_limit": today_limit,
"today_remaining": max(0, today_limit - today_used),
"usage_percentage": round((today_used / today_limit) * 100, 2) if today_limit > 0 else 0
}
@router.get("/token/daily", response_model=List[DailyUsageItem])
def get_daily_token_usage(
days: int = Query(30, ge=1, le=90, description="天数"),
db: Session = Depends(get_db),
token_service: TokenService = Depends(get_token_service)
):
"""获取每日 Token 使用"""
usages = token_service.get_daily_usages(days=days)
return [DailyUsageItem(date=u["date"], tokens=u["tokens"], comments=0, replies=0) for u in usages]
@router.get("/token/monthly", response_model=List[MonthlyUsageItem])
def get_monthly_token_usage(
months: int = Query(12, ge=1, le=24, description="月数"),
db: Session = Depends(get_db),
token_service: TokenService = Depends(get_token_service)
):
"""获取每月 Token 使用"""
usages = token_service.get_monthly_usages(months=months)
return [MonthlyUsageItem(month=u["month"], tokens=u["tokens"]) for u in usages]

View File

@@ -0,0 +1,72 @@
"""
互动管理 API
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.models.base import get_db
from app.models.interaction import InteractionType
from app.schemas.interaction import (
InteractionRecordResponse,
InteractionRecordListResponse,
InteractionExecuteRequest
)
from app.services.interaction_service import InteractionService, get_interaction_service
router = APIRouter()
@router.get("", response_model=InteractionRecordListResponse)
def get_interaction_records(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
virtual_user_id: Optional[int] = Query(None, description="虚拟用户 ID"),
interaction_type: Optional[InteractionType] = Query(None, description="互动类型"),
db: Session = Depends(get_db),
service: InteractionService = Depends(get_interaction_service)
):
"""获取互动记录列表"""
# TODO: 实现筛选和分页查询
return {"total": 0, "items": []}
@router.get("/{record_id}", response_model=InteractionRecordResponse)
def get_interaction_record(
record_id: int,
db: Session = Depends(get_db),
service: InteractionService = Depends(get_interaction_service)
):
"""获取互动记录详情"""
# TODO: 实现详情查询
raise HTTPException(status_code=404, detail="Record not found")
@router.post("/execute", response_model=InteractionRecordResponse)
async def execute_interaction(
request: InteractionExecuteRequest,
db: Session = Depends(get_db),
service: InteractionService = Depends(get_interaction_service)
):
"""执行互动"""
record = await service.execute_interaction(
virtual_user_id=request.virtual_user_id,
interaction_type=request.interaction_type,
news_id=request.news_id
)
if not record:
raise HTTPException(status_code=400, detail="Failed to execute interaction")
return record
@router.post("/retry/{record_id}")
async def retry_interaction(
record_id: int,
db: Session = Depends(get_db),
service: InteractionService = Depends(get_interaction_service)
):
"""重试失败的互动"""
# TODO: 实现重试逻辑
raise HTTPException(status_code=404, detail="Record not found")

19
backend/app/api/router.py Normal file
View File

@@ -0,0 +1,19 @@
"""
API 路由
"""
from fastapi import APIRouter
from .virtual_user import router as virtual_user_router
from .interaction import router as interaction_router
from .ai_model import router as ai_model_router
from .system_config import router as system_config_router
from .dashboard import router as dashboard_router
api_router = APIRouter()
# 注册各模块路由
api_router.include_router(virtual_user_router, prefix="/virtual-users", tags=["虚拟用户管理"])
api_router.include_router(interaction_router, prefix="/interactions", tags=["互动管理"])
api_router.include_router(ai_model_router, prefix="/ai-models", tags=["AI 模型配置"])
api_router.include_router(system_config_router, prefix="/system", tags=["系统设置"])
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["控制台"])

View File

@@ -0,0 +1,126 @@
"""
系统配置 API
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.models.base import get_db
from app.models.system_config import SystemConfig
from app.schemas.system_config import (
SystemConfigResponse,
SystemConfigUpdate,
ScheduleConfig,
LimitConfig,
ProbabilityConfig
)
from app.core.config import settings
router = APIRouter()
@router.get("", response_model=List[SystemConfigResponse])
def get_system_configs(
db: Session = Depends(get_db)
):
"""获取所有系统配置"""
configs = db.query(SystemConfig).all()
return configs
@router.get("/schedule", response_model=ScheduleConfig)
def get_schedule_config(
db: Session = Depends(get_db)
):
"""获取调度配置"""
from app.services.scheduler_service import scheduler_service
return ScheduleConfig(
task_start_hour=settings.TASK_START_HOUR,
task_end_hour=settings.TASK_END_HOUR,
task_interval_min=settings.TASK_INTERVAL_MIN,
task_interval_max=settings.TASK_INTERVAL_MAX,
is_task_running=scheduler_service.is_running
)
@router.get("/limits", response_model=LimitConfig)
def get_limit_config(
db: Session = Depends(get_db)
):
"""获取限额配置"""
return LimitConfig(
max_tokens_per_day=settings.MAX_TOKENS_PER_DAY,
max_comments_per_user_per_day=settings.MAX_COMMENTS_PER_USER_PER_DAY,
max_replies_per_user_per_day=settings.MAX_REPLIES_PER_USER_PER_DAY
)
@router.get("/probabilities", response_model=ProbabilityConfig)
def get_probability_config(
db: Session = Depends(get_db)
):
"""获取概率配置"""
return ProbabilityConfig(
like_probability=settings.LIKE_PROBABILITY,
favorite_probability=settings.FAVORITE_PROBABILITY,
share_probability=settings.SHARE_PROBABILITY
)
@router.put("/schedule")
def update_schedule_config(
config: ScheduleConfig,
db: Session = Depends(get_db)
):
"""更新调度配置"""
# TODO: 更新系统配置表并重新加载
return {"message": "Schedule config updated"}
@router.put("/limits")
def update_limit_config(
config: LimitConfig,
db: Session = Depends(get_db)
):
"""更新限额配置"""
# TODO: 更新系统配置表
return {"message": "Limit config updated"}
@router.post("/scheduler/start")
def start_scheduler(
db: Session = Depends(get_db)
):
"""启动定时任务"""
from app.services.scheduler_service import scheduler_service
scheduler_service.start()
scheduler_service.add_interaction_task()
return {"message": "Scheduler started", "running": scheduler_service.is_running}
@router.post("/scheduler/stop")
def stop_scheduler(
db: Session = Depends(get_db)
):
"""停止定时任务"""
from app.services.scheduler_service import scheduler_service
scheduler_service.stop()
return {"message": "Scheduler stopped", "running": scheduler_service.is_running}
@router.get("/scheduler/status")
def get_scheduler_status(
db: Session = Depends(get_db)
):
"""获取定时任务状态"""
from app.services.scheduler_service import scheduler_service
return {
"is_running": scheduler_service.is_running,
"jobs": [job.id for job in scheduler_service.scheduler.get_jobs()]
}

View File

@@ -0,0 +1,162 @@
"""
虚拟用户管理 API
"""
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from sqlalchemy.orm import Session
from typing import List, Optional
import pandas as pd
import io
from app.models.base import get_db
from app.schemas.virtual_user import (
VirtualUserCreate,
VirtualUserUpdate,
VirtualUserResponse,
VirtualUserListResponse,
VirtualUserGenerateRequest,
ActivityLevel,
UserStatus
)
from app.services.virtual_user_service import VirtualUserService, get_virtual_user_service
router = APIRouter()
@router.get("", response_model=VirtualUserListResponse)
def get_virtual_users(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
status: Optional[UserStatus] = Query(None, description="状态筛选"),
search: Optional[str] = Query(None, description="搜索关键词"),
db: Session = Depends(get_db),
service: VirtualUserService = Depends(get_virtual_user_service)
):
"""获取虚拟用户列表"""
result = service.get_users(page=page, page_size=page_size, status=status, search=search)
return result
@router.get("/{user_id}", response_model=VirtualUserResponse)
def get_virtual_user(
user_id: int,
db: Session = Depends(get_db),
service: VirtualUserService = Depends(get_virtual_user_service)
):
"""获取虚拟用户详情"""
user = service.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.post("", response_model=VirtualUserResponse)
def create_virtual_user(
user_data: VirtualUserCreate,
db: Session = Depends(get_db),
service: VirtualUserService = Depends(get_virtual_user_service)
):
"""创建虚拟用户"""
user = service.create_user(
username=user_data.username,
password=user_data.password,
nickname=user_data.nickname,
writing_style=user_data.writing_style,
activity_level=user_data.activity_level,
avatar_url=user_data.avatar_url,
persona_description=user_data.persona_description
)
if not user:
raise HTTPException(status_code=400, detail="Failed to create user (username may exist)")
return user
@router.post("/generate", response_model=VirtualUserListResponse)
def generate_virtual_users(
request: VirtualUserGenerateRequest,
db: Session = Depends(get_db),
service: VirtualUserService = Depends(get_virtual_user_service)
):
"""批量生成虚拟用户"""
users = service.generate_users(
count=request.count,
writing_styles=request.writing_styles,
activity_levels=request.activity_levels,
generate_persona=request.generate_persona
)
return {"total": len(users), "items": users}
@router.put("/{user_id}", response_model=VirtualUserResponse)
def update_virtual_user(
user_id: int,
user_data: VirtualUserUpdate,
db: Session = Depends(get_db),
service: VirtualUserService = Depends(get_virtual_user_service)
):
"""更新虚拟用户"""
update_data = user_data.model_dump(exclude_unset=True)
user = service.update_user(user_id, **update_data)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.delete("/{user_id}")
def delete_virtual_user(
user_id: int,
db: Session = Depends(get_db),
service: VirtualUserService = Depends(get_virtual_user_service)
):
"""删除虚拟用户"""
success = service.delete_user(user_id)
if not success:
raise HTTPException(status_code=404, detail="User not found")
return {"message": "User deleted successfully"}
@router.post("/import", response_model=dict)
def import_virtual_users(
file: UploadFile = File(...),
generate_persona: bool = Query(True, description="是否生成 AI 人格描述"),
db: Session = Depends(get_db),
service: VirtualUserService = Depends(get_virtual_user_service)
):
"""从 Excel 导入虚拟用户"""
try:
# 读取 Excel 文件
contents = file.file.read()
df = pd.read_excel(io.BytesIO(contents))
# 转换为字典列表
users_data = df.to_dict('records')
# 导入用户
result = service.import_users_from_excel(
users_data=users_data,
generate_persona=generate_persona
)
return {
"message": "Import completed",
"success_count": result["success_count"],
"failed_count": result["failed_count"]
}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Import failed: {str(e)}")
@router.get("/{user_id}/stats")
def get_user_stats(
user_id: int,
db: Session = Depends(get_db),
service: VirtualUserService = Depends(get_virtual_user_service)
):
"""获取用户统计信息"""
stats = service.get_user_stats(user_id)
return stats

View File

@@ -0,0 +1,6 @@
"""
核心模块初始化
"""
from .config import settings
__all__ = ["settings"]

View File

@@ -0,0 +1,76 @@
"""
系统配置模块
"""
from pydantic_settings import BaseSettings
from typing import Optional
import os
class Settings(BaseSettings):
"""应用配置"""
# 应用基础配置
APP_NAME: str = "会会虚拟用户 AI 互动系统"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
API_PREFIX: str = "/api/v1"
# 数据库配置
DATABASE_HOST: str = "mysql"
DATABASE_PORT: int = 3306
DATABASE_USER: str = "root"
DATABASE_PASSWORD: str = "root123456"
DATABASE_NAME: str = "huihui_ai_bot"
DATABASE_URL: Optional[str] = None
@property
def get_database_url(self) -> str:
if self.DATABASE_URL:
return self.DATABASE_URL
return f"mysql+pymysql://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}?charset=utf8mb4"
# JWT 配置
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 天
# 会会接口配置
HUIHUI_API_BASE: str = "http://192.168.1.200:63120"
HUIHUI_DOC_URL: str = "http://192.168.1.200:63120/doc.html"
# AI 模型配置(默认)
DEFAULT_AI_MODEL: str = "openai"
OPENAI_API_KEY: Optional[str] = None
OPENAI_BASE_URL: str = "https://api.openai.com/v1"
OPENAI_MODEL: str = "gpt-3.5-turbo"
ZHIPU_API_KEY: Optional[str] = None
ZHIPU_MODEL: str = "glm-4"
# 系统限制配置
MAX_TOKENS_PER_DAY: int = 10000 # 每日 Token 上限
MAX_COMMENTS_PER_USER_PER_DAY: int = 20 # 单用户每日最大评论数
MAX_REPLIES_PER_USER_PER_DAY: int = 10 # 单用户每日最大回复数
# 定时任务配置
TASK_START_HOUR: int = 9 # 活动开始时间
TASK_END_HOUR: int = 22 # 活动结束时间
TASK_INTERVAL_MIN: int = 10 # 最小间隔(分钟)
TASK_INTERVAL_MAX: int = 30 # 最大间隔(分钟)
# 互动概率配置
LIKE_PROBABILITY: float = 0.8 # 点赞概率
FAVORITE_PROBABILITY: float = 0.5 # 收藏概率
SHARE_PROBABILITY: float = 0.3 # 转发概率
# 文件存储配置
UPLOAD_DIR: str = "/app/data/uploads"
LOG_DIR: str = "/app/data/logs"
class Config:
env_file = ".env"
case_sensitive = True
# 创建全局配置实例
settings = Settings()

95
backend/app/main.py Normal file
View File

@@ -0,0 +1,95 @@
"""
FastAPI 应用主文件
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger as loguru_logger
from app.core.config import settings
from app.models.base import init_db
from app.api.router import api_router
from app.services.scheduler_service import scheduler_service
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时执行
logger.info("Starting application...")
# 初始化数据库
init_db()
logger.info("Database initialized")
# 启动定时任务
scheduler_service.start()
scheduler_service.add_interaction_task()
scheduler_service.add_login_task(hour=8, minute=0)
scheduler_service.reset_daily_counters(hour=0, minute=1)
logger.info("Scheduler started")
yield
# 关闭时执行
logger.info("Shutting down application...")
scheduler_service.stop()
# 创建 FastAPI 应用
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="会会虚拟用户 AI 互动系统后端 API",
lifespan=lifespan
)
# 配置 CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境应该配置具体的域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(api_router, prefix=settings.API_PREFIX)
@app.get("/")
async def root():
"""根路径"""
return {
"name": settings.APP_NAME,
"version": settings.APP_VERSION,
"status": "running"
}
@app.get("/health")
async def health_check():
"""健康检查"""
return {
"status": "healthy",
"scheduler_running": scheduler_service.is_running
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG
)

View File

@@ -0,0 +1,25 @@
"""
数据库模型初始化
"""
from .base import Base, engine, get_db, SessionLocal
from .virtual_user import VirtualUser, VirtualUserPersona
from .interaction import InteractionRecord, InteractionType
from .token_usage import TokenUsage
from .system_config import SystemConfig
from .ai_model import AIModelConfig
from .news_cache import NewsCache
__all__ = [
"Base",
"engine",
"get_db",
"SessionLocal",
"VirtualUser",
"VirtualUserPersona",
"InteractionRecord",
"InteractionType",
"TokenUsage",
"SystemConfig",
"AIModelConfig",
"NewsCache",
]

View File

@@ -0,0 +1,43 @@
"""
AI 模型配置模型
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Float
from sqlalchemy.sql import func
from .base import Base
class AIModelConfig(Base):
"""AI 模型配置表"""
__tablename__ = "ai_model_configs"
id = Column(Integer, primary_key=True, autoincrement=True, comment="配置 ID")
# 模型基本信息
model_name = Column(String(100), unique=True, nullable=False, index=True, comment="模型名称(如 gpt-3.5-turbo")
provider = Column(String(50), nullable=False, comment="提供商openai/zhipu/baidu/aliyun")
display_name = Column(String(200), comment="显示名称")
# API 配置
api_url = Column(String(500), nullable=False, comment="API 地址")
api_key = Column(String(500), nullable=False, comment="API Key加密存储")
api_version = Column(String(50), comment="API 版本")
# 模型参数
temperature = Column(Float, default=0.7, comment="温度0-1")
max_tokens = Column(Integer, default=1000, comment="最大 Token 数")
top_p = Column(Float, default=1.0, comment="Top P 参数")
# 状态控制
is_default = Column(Boolean, default=False, comment="是否为默认模型")
is_active = Column(Boolean, default=True, comment="是否启用")
# 描述信息
description = Column(Text, comment="模型描述")
# 时间戳
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<AIModelConfig(id={self.id}, name='{self.model_name}', provider='{self.provider}')>"

View File

@@ -0,0 +1,49 @@
"""
数据库基础配置
"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from contextlib import contextmanager
import logging
from app.core.config import settings
logger = logging.getLogger(__name__)
# 创建数据库引擎
engine = create_engine(
settings.get_database_url,
pool_pre_ping=True,
pool_size=20,
max_overflow=40,
echo=settings.DEBUG,
)
# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基类
Base = declarative_base()
@contextmanager
def get_db():
"""获取数据库会话的上下文管理器"""
db = SessionLocal()
try:
yield db
db.commit()
except Exception as e:
db.rollback()
logger.error(f"Database error: {e}")
raise
finally:
db.close()
def init_db():
"""初始化数据库表"""
from . import virtual_user, interaction, token_usage, system_config, ai_model, news_cache
Base.metadata.create_all(bind=engine)
logger.info("Database tables created successfully")

View File

@@ -0,0 +1,68 @@
"""
互动记录模型
"""
from sqlalchemy import Column, Integer, String, DateTime, Enum, Text, ForeignKey, Boolean, Float
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
import enum
from .base import Base
class InteractionType(str, enum.Enum):
"""互动类型枚举"""
COMMENT = "comment" # 评论
REPLY = "reply" # 回复
LIKE = "like" # 点赞
FAVORITE = "favorite" # 收藏
SHARE = "share" # 转发
class InteractionStatus(str, enum.Enum):
"""互动状态枚举"""
PENDING = "pending" # 待执行
SUCCESS = "success" # 成功
FAILED = "failed" # 失败
RETRYING = "retrying" # 重试中
class InteractionRecord(Base):
"""互动记录表"""
__tablename__ = "interaction_records"
id = Column(Integer, primary_key=True, autoincrement=True, comment="记录 ID")
# 关联信息
virtual_user_id = Column(Integer, ForeignKey("virtual_users.id"), nullable=False, index=True, comment="虚拟用户 ID")
virtual_user = relationship("VirtualUser", backref="interaction_records")
news_id = Column(String(100), nullable=False, index=True, comment="新闻 ID")
news_title = Column(String(500), comment="新闻标题")
# 互动内容
interaction_type = Column(Enum(InteractionType), nullable=False, comment="互动类型")
content = Column(Text, comment="互动内容(评论/回复的文本)")
target_comment_id = Column(String(100), comment="目标评论 ID回复时使用")
# 执行状态
status = Column(Enum(InteractionStatus), default=InteractionStatus.PENDING, comment="执行状态")
retry_count = Column(Integer, default=0, comment="重试次数")
error_message = Column(Text, comment="错误信息(失败时)")
# AI 相关信息
ai_model_used = Column(String(100), comment="使用的 AI 模型")
tokens_used = Column(Integer, default=0, comment="消耗的 Token 数")
prompt_content = Column(Text, comment="发送给 AI 的提示词")
ai_response = Column(Text, comment="AI 返回的内容")
# 接口响应
api_response = Column(Text, comment="会会接口返回的原始响应")
api_request_id = Column(String(200), comment="接口请求 ID")
# 时间戳
execution_time = Column(DateTime, server_default=func.now(), comment="执行时间")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<InteractionRecord(id={self.id}, user_id={self.virtual_user_id}, type='{self.interaction_type}', status='{self.status}')>"

View File

@@ -0,0 +1,45 @@
"""
新闻缓存模型
"""
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, Date
from sqlalchemy.sql import func
from .base import Base
class NewsCache(Base):
"""新闻缓存表"""
__tablename__ = "news_cache"
id = Column(Integer, primary_key=True, autoincrement=True, comment="缓存 ID")
# 新闻基本信息
news_id = Column(String(100), unique=True, nullable=False, index=True, comment="新闻 ID来自会会接口")
title = Column(String(500), nullable=False, comment="新闻标题")
summary = Column(Text, comment="新闻摘要")
content = Column(Text, comment="新闻内容")
# 来源信息
source = Column(String(200), comment="来源")
author = Column(String(100), comment="作者")
publish_time = Column(DateTime, comment="发布时间")
# 分类标签
category = Column(String(100), comment="分类")
tags = Column(String(500), comment="标签(逗号分隔)")
# 互动统计
view_count = Column(Integer, default=0, comment="阅读数")
comment_count = Column(Integer, default=0, comment="评论数")
like_count = Column(Integer, default=0, comment="点赞数")
# 缓存状态
is_cached = Column(Boolean, default=True, comment="是否已缓存")
cache_date = Column(Date, server_default=func.now(), index=True, comment="缓存日期")
# 时间戳
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<NewsCache(id={self.id}, news_id='{self.news_id}', title='{self.title[:30]}...')>"

View File

@@ -0,0 +1,32 @@
"""
系统配置模型
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, JSON
from sqlalchemy.sql import func
from .base import Base
class SystemConfig(Base):
"""系统配置表"""
__tablename__ = "system_configs"
id = Column(Integer, primary_key=True, autoincrement=True, comment="配置 ID")
# 配置键值
config_key = Column(String(100), unique=True, nullable=False, index=True, comment="配置键")
config_value = Column(JSON, nullable=False, comment="配置值JSON 格式)")
config_type = Column(String(50), comment="配置类型schedule/limit/probability等")
# 描述信息
description = Column(Text, comment="配置描述")
# 状态
is_active = Column(Boolean, default=True, comment="是否启用")
# 时间戳
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<SystemConfig(id={self.id}, key='{self.config_key}')>"

View File

@@ -0,0 +1,36 @@
"""
Token 使用记录模型
"""
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Date
from sqlalchemy.sql import func
from .base import Base
class TokenUsage(Base):
"""Token 使用记录表"""
__tablename__ = "token_usages"
id = Column(Integer, primary_key=True, autoincrement=True, comment="记录 ID")
# 关联信息
virtual_user_id = Column(Integer, ForeignKey("virtual_users.id"), nullable=True, index=True, comment="虚拟用户 ID可为空系统级消耗")
interaction_id = Column(Integer, ForeignKey("interaction_records.id"), nullable=True, comment="互动记录 ID")
# Token 信息
tokens_used = Column(Integer, nullable=False, comment="使用的 Token 数量")
tokens_prompt = Column(Integer, default=0, comment="提示词 Token 数")
tokens_completion = Column(Integer, default=0, comment="完成响应 Token 数")
# AI 模型信息
ai_model = Column(String(100), nullable=False, comment="使用的 AI 模型")
action_type = Column(String(50), comment="操作类型generate_comment/generate_reply 等)")
# 日期分区(便于统计)
usage_date = Column(Date, server_default=func.now(), index=True, comment="使用日期")
# 时间戳
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
def __repr__(self):
return f"<TokenUsage(id={self.id}, tokens={self.tokens_used}, model='{self.ai_model}')>"

View File

@@ -0,0 +1,91 @@
"""
虚拟用户模型
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Enum, Text, JSON, Float
from sqlalchemy.sql import func
from enum import Enum as PyEnum
import enum
from .base import Base
class ActivityLevel(str, enum.Enum):
"""活跃度枚举"""
LOW = "low" # 低:每日 1-2 次
MEDIUM = "medium" # 中:每日 2-5 次
HIGH = "high" # 高:每日 5-10 次
class UserStatus(str, enum.Enum):
"""用户状态枚举"""
ACTIVE = "active" # 启用
DISABLED = "disabled" # 禁用
class VirtualUser(Base):
"""虚拟用户表"""
__tablename__ = "virtual_users"
id = Column(Integer, primary_key=True, autoincrement=True, comment="用户 ID")
# 基本信息
username = Column(String(100), unique=True, nullable=False, index=True, comment="用户名(账号)")
password = Column(String(200), nullable=False, comment="密码(加密存储)")
nickname = Column(String(100), nullable=False, comment="昵称")
avatar_url = Column(String(500), comment="头像 URL")
# 人格特征
writing_style = Column(String(50), comment="写作风格")
activity_level = Column(Enum(ActivityLevel), default=ActivityLevel.MEDIUM, comment="活跃度")
persona_description = Column(Text, comment="人格描述AI 生成)")
# 状态控制
status = Column(Enum(UserStatus), default=UserStatus.ACTIVE, comment="状态")
is_logged_in = Column(Boolean, default=False, comment="是否已登录")
session_token = Column(String(500), comment="会话 Token登录后")
token_expire_time = Column(DateTime, comment="Token 过期时间")
# 互动统计
total_interactions = Column(Integer, default=0, comment="总互动次数")
today_comments = Column(Integer, default=0, comment="今日评论数")
today_replies = Column(Integer, default=0, comment="今日回复数")
last_interaction_time = Column(DateTime, comment="最后互动时间")
# 扩展信息
extra_info = Column(JSON, default=dict, comment="扩展信息")
# 时间戳
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<VirtualUser(id={self.id}, nickname='{self.nickname}', status='{self.status}')>"
class VirtualUserPersona(Base):
"""虚拟用户人格模板表"""
__tablename__ = "virtual_user_personas"
id = Column(Integer, primary_key=True, autoincrement=True, comment="ID")
# 人格特征
name = Column(String(100), unique=True, nullable=False, comment="人格名称")
description = Column(Text, comment="人格描述")
# 风格配置
writing_styles = Column(JSON, comment="写作风格列表")
personality_traits = Column(JSON, comment="性格特征列表")
speech_patterns = Column(JSON, comment="说话模式列表")
# AI 提示词
system_prompt = Column(Text, comment="系统提示词(用于 AI 生成)")
# 状态
is_active = Column(Boolean, default=True, comment="是否启用")
# 时间戳
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<VirtualUserPersona(id={self.id}, name='{self.name}')>"

View File

@@ -0,0 +1,53 @@
"""
Pydantic Schema 定义
"""
from .virtual_user import (
VirtualUserCreate,
VirtualUserUpdate,
VirtualUserResponse,
VirtualUserListResponse,
VirtualUserGenerateRequest,
VirtualUserImportRequest,
ActivityLevel,
UserStatus,
)
from .interaction import (
InteractionRecordResponse,
InteractionRecordListResponse,
InteractionType,
InteractionStatus,
)
from .token_usage import TokenUsageResponse, TokenUsageStats
from .system_config import SystemConfigResponse, SystemConfigUpdate
from .ai_model import AIModelConfigCreate, AIModelConfigUpdate, AIModelConfigResponse
from .dashboard import DashboardStats, DashboardTokenStats
__all__ = [
# Virtual User
"VirtualUserCreate",
"VirtualUserUpdate",
"VirtualUserResponse",
"VirtualUserListResponse",
"VirtualUserGenerateRequest",
"VirtualUserImportRequest",
"ActivityLevel",
"UserStatus",
# Interaction
"InteractionRecordResponse",
"InteractionRecordListResponse",
"InteractionType",
"InteractionStatus",
# Token Usage
"TokenUsageResponse",
"TokenUsageStats",
# System Config
"SystemConfigResponse",
"SystemConfigUpdate",
# AI Model
"AIModelConfigCreate",
"AIModelConfigUpdate",
"AIModelConfigResponse",
# Dashboard
"DashboardStats",
"DashboardTokenStats",
]

View File

@@ -0,0 +1,64 @@
"""
AI 模型配置相关 Schema
"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
class AIModelConfigBase(BaseModel):
"""AI 模型配置基础 Schema"""
model_name: str = Field(..., description="模型名称", max_length=100)
provider: str = Field(..., description="提供商", max_length=50)
display_name: Optional[str] = Field(None, description="显示名称", max_length=200)
api_url: str = Field(..., description="API 地址", max_length=500)
api_key: str = Field(..., description="API Key", max_length=500)
temperature: float = Field(0.7, description="温度", ge=0, le=1)
max_tokens: int = Field(1000, description="最大 Token 数", ge=1)
class AIModelConfigCreate(AIModelConfigBase):
"""创建 AI 模型配置请求"""
description: Optional[str] = Field(None, description="模型描述")
class AIModelConfigUpdate(BaseModel):
"""更新 AI 模型配置请求"""
display_name: Optional[str] = Field(None, description="显示名称", max_length=200)
api_url: Optional[str] = Field(None, description="API 地址", max_length=500)
api_key: Optional[str] = Field(None, description="API Key", max_length=500)
temperature: Optional[float] = Field(None, description="温度", ge=0, le=1)
max_tokens: Optional[int] = Field(None, description="最大 Token 数", ge=1)
is_default: Optional[bool] = Field(None, description="是否为默认模型")
is_active: Optional[bool] = Field(None, description="是否启用")
description: Optional[str] = Field(None, description="模型描述")
class AIModelConfigResponse(AIModelConfigBase):
"""AI 模型配置响应"""
id: int
api_version: Optional[str]
top_p: float
is_default: bool
is_active: bool
description: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class AIModelTestRequest(BaseModel):
"""AI 模型测试请求"""
model_id: int = Field(..., description="模型 ID")
test_prompt: str = Field(..., description="测试提示词", min_length=1, max_length=1000)
class AIModelTestResponse(BaseModel):
"""AI 模型测试响应"""
success: bool
content: Optional[str]
tokens_used: int
cost_time: float
error_message: Optional[str]

View File

@@ -0,0 +1,55 @@
"""
控制台仪表盘相关 Schema
"""
from pydantic import BaseModel, Field
from typing import List, Optional
class CoreStats(BaseModel):
"""核心指标统计"""
total_users: int = Field(0, description="虚拟用户总数")
active_users: int = Field(0, description="已启用用户数")
disabled_users: int = Field(0, description="已禁用用户数")
today_comments: int = Field(0, description="今日评论数")
today_replies: int = Field(0, description="今日回复数")
today_likes: int = Field(0, description="今日点赞数")
today_favorites: int = Field(0, description="今日收藏数")
today_shares: int = Field(0, description="今日转发数")
yesterday_comments: int = Field(0, description="昨日评论数")
yesterday_replies: int = Field(0, description="昨日回复数")
month_tokens: int = Field(0, description="当月 Token 消耗")
today_tokens: int = Field(0, description="今日 Token 消耗")
remaining_tokens: int = Field(0, description="今日剩余 Token")
class DashboardTokenStats(BaseModel):
"""Token 统计"""
today_used: int = Field(0, description="今日已用")
today_limit: int = Field(0, description="今日限额")
today_remaining: int = Field(0, description="今日剩余")
usage_percentage: float = Field(0, description="使用百分比")
class DailyUsageItem(BaseModel):
"""每日使用项"""
date: str
tokens: int
comments: int
replies: int
class MonthlyUsageItem(BaseModel):
"""每月使用项"""
month: str
tokens: int
class DashboardStats(BaseModel):
"""控制台统计数据"""
core_stats: CoreStats
daily_token_usages: List[DailyUsageItem] = Field(default_factory=list)
monthly_token_usages: List[MonthlyUsageItem] = Field(default_factory=list)
recent_interactions: List[dict] = Field(default_factory=list)

View File

@@ -0,0 +1,63 @@
"""
互动记录相关 Schema
"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
class InteractionType(str, Enum):
"""互动类型枚举"""
COMMENT = "comment"
REPLY = "reply"
LIKE = "like"
FAVORITE = "favorite"
SHARE = "share"
class InteractionStatus(str, Enum):
"""互动状态枚举"""
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
RETRYING = "retrying"
class InteractionRecordBase(BaseModel):
"""互动记录基础 Schema"""
virtual_user_id: int = Field(..., description="虚拟用户 ID")
news_id: str = Field(..., description="新闻 ID")
interaction_type: InteractionType = Field(..., description="互动类型")
content: Optional[str] = Field(None, description="互动内容")
target_comment_id: Optional[str] = Field(None, description="目标评论 ID")
class InteractionRecordResponse(InteractionRecordBase):
"""互动记录响应"""
id: int
news_title: Optional[str]
status: InteractionStatus
retry_count: int
error_message: Optional[str]
ai_model_used: Optional[str]
tokens_used: int
execution_time: datetime
created_at: datetime
class Config:
from_attributes = True
class InteractionRecordListResponse(BaseModel):
"""互动记录列表响应"""
total: int
items: List[InteractionRecordResponse]
class InteractionExecuteRequest(BaseModel):
"""执行互动请求"""
virtual_user_id: int = Field(..., description="虚拟用户 ID")
news_id: Optional[str] = Field(None, description="新闻 ID不传则随机选择")
interaction_type: Optional[InteractionType] = Field(None, description="互动类型(不传则随机)")
force_execute: bool = Field(False, description="是否强制执行(忽略限额)")

View File

@@ -0,0 +1,60 @@
"""
系统配置相关 Schema
"""
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
from datetime import datetime
class SystemConfigBase(BaseModel):
"""系统配置基础 Schema"""
config_key: str = Field(..., description="配置键", max_length=100)
config_value: Dict[str, Any] = Field(..., description="配置值")
config_type: Optional[str] = Field(None, description="配置类型", max_length=50)
description: Optional[str] = Field(None, description="配置描述")
class SystemConfigCreate(SystemConfigBase):
"""创建系统配置请求"""
pass
class SystemConfigUpdate(BaseModel):
"""更新系统配置请求"""
config_value: Optional[Dict[str, Any]] = Field(None, description="配置值")
description: Optional[str] = Field(None, description="配置描述")
is_active: Optional[bool] = Field(None, description="是否启用")
class SystemConfigResponse(SystemConfigBase):
"""系统配置响应"""
id: int
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ScheduleConfig(BaseModel):
"""调度配置"""
task_start_hour: int = Field(9, description="活动开始时间", ge=0, le=23)
task_end_hour: int = Field(22, description="活动结束时间", ge=0, le=23)
task_interval_min: int = Field(10, description="最小间隔(分钟)", ge=1)
task_interval_max: int = Field(30, description="最大间隔(分钟)", ge=1)
is_task_running: bool = Field(False, description="任务是否运行中")
class LimitConfig(BaseModel):
"""限额配置"""
max_tokens_per_day: int = Field(10000, description="每日 Token 上限", ge=0)
max_comments_per_user_per_day: int = Field(20, description="单用户每日最大评论数", ge=0)
max_replies_per_user_per_day: int = Field(10, description="单用户每日最大回复数", ge=0)
class ProbabilityConfig(BaseModel):
"""概率配置"""
like_probability: float = Field(0.8, description="点赞概率", ge=0, le=1)
favorite_probability: float = Field(0.5, description="收藏概率", ge=0, le=1)
share_probability: float = Field(0.3, description="转发概率", ge=0, le=1)

View File

@@ -0,0 +1,54 @@
"""
Token 使用相关 Schema
"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import date, datetime
class TokenUsageBase(BaseModel):
"""Token 使用基础 Schema"""
tokens_used: int = Field(..., description="使用的 Token 数量")
ai_model: str = Field(..., description="使用的 AI 模型")
action_type: Optional[str] = Field(None, description="操作类型")
class TokenUsageResponse(TokenUsageBase):
"""Token 使用响应"""
id: int
virtual_user_id: Optional[int]
interaction_id: Optional[int]
tokens_prompt: int
tokens_completion: int
usage_date: date
created_at: datetime
class Config:
from_attributes = True
class TokenUsageStats(BaseModel):
"""Token 使用统计"""
today_tokens: int = Field(0, description="今日 Token 数")
yesterday_tokens: int = Field(0, description="昨日 Token 数")
month_tokens: int = Field(0, description="当月 Token 数")
remaining_tokens: int = Field(0, description="剩余 Token 数")
total_limit: int = Field(0, description="总限额")
class DailyTokenUsage(BaseModel):
"""每日 Token 使用"""
date: str
tokens: int
class MonthlyTokenUsage(BaseModel):
"""每月 Token 使用"""
month: str
tokens: int
class TokenUsageChartResponse(BaseModel):
"""Token 使用图表响应"""
daily_usages: List[DailyTokenUsage]
monthly_usages: List[MonthlyTokenUsage]

View File

@@ -0,0 +1,86 @@
"""
虚拟用户相关 Schema
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
class ActivityLevel(str, Enum):
"""活跃度枚举"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class UserStatus(str, Enum):
"""用户状态枚举"""
ACTIVE = "active"
DISABLED = "disabled"
class VirtualUserBase(BaseModel):
"""虚拟用户基础 Schema"""
nickname: str = Field(..., description="昵称", min_length=1, max_length=100)
username: str = Field(..., description="用户名(账号)", min_length=1, max_length=100)
password: str = Field(..., description="密码", min_length=1)
avatar_url: Optional[str] = Field(None, description="头像 URL", max_length=500)
writing_style: Optional[str] = Field(None, description="写作风格", max_length=50)
activity_level: ActivityLevel = Field(default=ActivityLevel.MEDIUM, description="活跃度")
persona_description: Optional[str] = Field(None, description="人格描述")
class VirtualUserCreate(VirtualUserBase):
"""创建虚拟用户请求"""
pass
class VirtualUserUpdate(BaseModel):
"""更新虚拟用户请求"""
nickname: Optional[str] = Field(None, description="昵称", min_length=1, max_length=100)
password: Optional[str] = Field(None, description="密码", min_length=1)
avatar_url: Optional[str] = Field(None, description="头像 URL", max_length=500)
writing_style: Optional[str] = Field(None, description="写作风格", max_length=50)
activity_level: Optional[ActivityLevel] = Field(None, description="活跃度")
persona_description: Optional[str] = Field(None, description="人格描述")
status: Optional[UserStatus] = Field(None, description="状态")
class VirtualUserResponse(VirtualUserBase):
"""虚拟用户响应"""
id: int
status: UserStatus
is_logged_in: bool
total_interactions: int
today_comments: int
today_replies: int
last_interaction_time: Optional[datetime]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class VirtualUserListResponse(BaseModel):
"""虚拟用户列表响应"""
total: int
items: List[VirtualUserResponse]
class VirtualUserGenerateRequest(BaseModel):
"""生成虚拟用户请求"""
count: int = Field(1, description="生成数量", ge=1, le=100)
writing_styles: Optional[List[str]] = Field(None, description="写作风格列表")
activity_levels: Optional[List[ActivityLevel]] = Field(
[ActivityLevel.LOW, ActivityLevel.MEDIUM, ActivityLevel.HIGH],
description="活跃度级别列表"
)
generate_persona: bool = Field(True, description="是否生成 AI 人格描述")
class VirtualUserImportRequest(BaseModel):
"""导入虚拟用户请求"""
users: List[Dict[str, Any]] = Field(..., description="用户数据列表")
generate_persona: bool = Field(True, description="是否为导入的用户生成 AI 人格描述")

View File

@@ -0,0 +1,18 @@
"""
服务层模块初始化
"""
from .huihui_api import HuihuiAPIService
from .ai_service import AIService
from .virtual_user_service import VirtualUserService
from .interaction_service import InteractionService
from .token_service import TokenService
from .scheduler_service import SchedulerService
__all__ = [
"HuihuiAPIService",
"AIService",
"VirtualUserService",
"InteractionService",
"TokenService",
"SchedulerService",
]

View File

@@ -0,0 +1,301 @@
"""
AI 大模型对接服务
支持 OpenAI、智谱、百度文心、阿里通义等主流大模型
"""
import logging
from typing import Optional, Dict, Any, List
from datetime import datetime
import json
logger = logging.getLogger(__name__)
class AIService:
"""AI 服务类"""
def __init__(self):
self._client_cache: Dict[str, Any] = {}
async def generate_comment(
self,
news_content: str,
writing_style: str,
persona_description: Optional[str] = None,
model_config: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
"""
AI 生成评论
:param news_content: 新闻内容
:param writing_style: 写作风格
:param persona_description: 人格描述
:param model_config: 模型配置
:return: 生成结果(包含 content, tokens_used 等)
"""
prompt = self._build_comment_prompt(
news_content,
writing_style,
persona_description
)
return await self._call_ai_api(prompt, model_config)
async def generate_reply(
self,
original_comment: str,
news_content: str,
writing_style: str,
persona_description: Optional[str] = None,
model_config: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
"""
AI 生成回复
:param original_comment: 原评论
:param news_content: 新闻内容
:param writing_style: 写作风格
:param persona_description: 人格描述
:param model_config: 模型配置
:return: 生成结果
"""
prompt = self._build_reply_prompt(
original_comment,
news_content,
writing_style,
persona_description
)
return await self._call_ai_api(prompt, model_config)
def _build_comment_prompt(
self,
news_content: str,
writing_style: str,
persona_description: Optional[str] = None
) -> str:
"""构建评论提示词"""
base_prompt = f"""你是一位虚拟用户,请根据以下要求写一条简短的评论:
写作风格:{writing_style}
"""
if persona_description:
base_prompt += f"\n人格特征:{persona_description}\n"
base_prompt += f"""
新闻内容:
{news_content[:1000]} # 限制长度
请写一条 50-100 字的评论,要符合你的写作风格和人格特征。直接输出评论内容,不要有其他说明。"""
return base_prompt
def _build_reply_prompt(
self,
original_comment: str,
news_content: str,
writing_style: str,
persona_description: Optional[str] = None
) -> str:
"""构建回复提示词"""
base_prompt = f"""你是一位虚拟用户,请根据以下要求回复另一条评论:
写作风格:{writing_style}
"""
if persona_description:
base_prompt += f"\n人格特征:{persona_description}\n"
base_prompt += f"""
新闻内容:
{news_content[:500]}
原评论:
{original_comment}
请写一条 30-80 字的回复,要符合你的写作风格和人格特征。直接输出回复内容,不要有其他说明。"""
return base_prompt
async def _call_ai_api(
self,
prompt: str,
model_config: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
"""
调用 AI API根据 model_config 中的 provider 选择对应模型)
:param prompt: 提示词
:param model_config: 模型配置
:return: 生成结果
"""
if not model_config:
# 使用默认配置(需要从数据库加载)
from app.models.ai_model import AIModelConfig
from app.models.base import get_db
with get_db() as db:
default_model = db.query(AIModelConfig).filter(
AIModelConfig.is_default == True,
AIModelConfig.is_active == True
).first()
if not default_model:
logger.error("No default AI model configured")
return None
model_config = {
"provider": default_model.provider,
"model_name": default_model.model_name,
"api_key": default_model.api_key,
"api_url": default_model.api_url,
"temperature": default_model.temperature,
"max_tokens": default_model.max_tokens,
}
provider = model_config.get("provider", "").lower()
try:
if provider == "openai":
return await self._call_openai(prompt, model_config)
elif provider == "zhipu":
return await self._call_zhipu(prompt, model_config)
elif provider in ["baidu", "wenxin"]:
return await self._call_baidu_wenxin(prompt, model_config)
elif provider in ["aliyun", "dashscope"]:
return await self._call_aliyun_dashscope(prompt, model_config)
else:
logger.error(f"Unsupported AI provider: {provider}")
return None
except Exception as e:
logger.error(f"AI API call error: {e}")
return None
async def _call_openai(
self,
prompt: str,
config: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""调用 OpenAI API"""
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=config["api_key"],
base_url=config.get("api_url")
)
response = await client.chat.completions.create(
model=config.get("model_name", "gpt-3.5-turbo"),
messages=[
{"role": "user", "content": prompt}
],
temperature=config.get("temperature", 0.7),
max_tokens=config.get("max_tokens", 1000)
)
content = response.choices[0].message.content
tokens_used = response.usage.total_tokens if response.usage else 0
logger.info(f"OpenAI generated content, tokens: {tokens_used}")
return {
"content": content,
"tokens_used": tokens_used,
"provider": "openai",
"model": config.get("model_name", "gpt-3.5-turbo")
}
except Exception as e:
logger.error(f"OpenAI API error: {e}")
return None
async def _call_zhipu(
self,
prompt: str,
config: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""调用智谱 AI API"""
try:
from zhipuai import ZhipuAI
client = ZhipuAI(api_key=config["api_key"])
response = client.chat.completions.create(
model=config.get("model_name", "glm-4"),
messages=[
{"role": "user", "content": prompt}
],
temperature=config.get("temperature", 0.7),
max_tokens=config.get("max_tokens", 1000)
)
content = response.choices[0].message.content
tokens_used = response.usage.total_tokens if response.usage else 0
logger.info(f"Zhipu AI generated content, tokens: {tokens_used}")
return {
"content": content,
"tokens_used": tokens_used,
"provider": "zhipu",
"model": config.get("model_name", "glm-4")
}
except Exception as e:
logger.error(f"Zhipu AI API error: {e}")
return None
async def _call_baidu_wenxin(
self,
prompt: str,
config: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""调用百度文心一言 API"""
# TODO: 实现百度文心一言 API 调用
logger.warning("Baidu Wenxin API not implemented yet")
return None
async def _call_aliyun_dashscope(
self,
prompt: str,
config: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""调用阿里云通义千问 API"""
# TODO: 实现阿里云 DashScope API 调用
logger.warning("Aliyun DashScope API not implemented yet")
return None
async def test_model(
self,
model_config: Dict[str, Any],
test_prompt: str = "测试评论"
) -> Dict[str, Any]:
"""
测试模型配置
:param model_config: 模型配置
:param test_prompt: 测试提示词
:return: 测试结果
"""
import time
start_time = time.time()
result = await self._call_ai_api(test_prompt, model_config)
cost_time = time.time() - start_time
if result:
return {
"success": True,
"content": result.get("content"),
"tokens_used": result.get("tokens_used", 0),
"cost_time": round(cost_time, 2),
"error_message": None
}
else:
return {
"success": False,
"content": None,
"tokens_used": 0,
"cost_time": round(cost_time, 2),
"error_message": "Failed to generate content"
}
# 创建全局服务实例
ai_service = AIService()

View File

@@ -0,0 +1,291 @@
"""
会会接口对接服务
基于 http://192.168.1.200:63120/doc.html 接口文档
"""
import httpx
import logging
from typing import Optional, Dict, Any, List
from datetime import datetime
from app.core.config import settings
logger = logging.getLogger(__name__)
class HuihuiAPIService:
"""会会 API 服务类"""
def __init__(self):
self.base_url = settings.HUIHUI_API_BASE
self.timeout = 30 # 秒
self._session_cache: Dict[str, httpx.AsyncClient] = {}
def _get_client(self, session_token: Optional[str] = None) -> httpx.AsyncClient:
"""获取 HTTP 客户端"""
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if session_token:
headers["Authorization"] = f"Bearer {session_token}"
return httpx.AsyncClient(
base_url=self.base_url,
headers=headers,
timeout=self.timeout,
)
async def login(self, username: str, password: str) -> Optional[Dict[str, Any]]:
"""
用户登录
:param username: 用户名
:param password: 密码
:return: 登录响应(包含 session token
"""
try:
async with self._get_client() as client:
response = await client.post(
"/api/login", # 实际接口路径需根据 doc.html 调整
json={"username": username, "password": password}
)
if response.status_code == 200:
data = response.json()
logger.info(f"Login success for user: {username}")
return data
else:
logger.error(f"Login failed: {response.status_code} - {response.text}")
return None
except Exception as e:
logger.error(f"Login error: {e}")
return None
async def get_news_list(
self,
page: int = 1,
page_size: int = 20,
category: Optional[str] = None
) -> Optional[List[Dict[str, Any]]]:
"""
获取新闻列表
:param page: 页码
:param page_size: 每页数量
:param category: 分类(可选)
:return: 新闻列表
"""
try:
async with self._get_client() as client:
params = {"page": page, "pageSize": page_size}
if category:
params["category"] = category
response = await client.get(
"/api/news/list", # 实际接口路径需根据 doc.html 调整
params=params
)
if response.status_code == 200:
data = response.json()
return data.get("data", [])
else:
logger.error(f"Get news list failed: {response.status_code}")
return None
except Exception as e:
logger.error(f"Get news list error: {e}")
return None
async def get_news_detail(self, news_id: str) -> Optional[Dict[str, Any]]:
"""
获取新闻详情
:param news_id: 新闻 ID
:return: 新闻详情
"""
try:
async with self._get_client() as client:
response = await client.get(f"/api/news/{news_id}")
if response.status_code == 200:
data = response.json()
return data.get("data")
else:
logger.error(f"Get news detail failed: {response.status_code}")
return None
except Exception as e:
logger.error(f"Get news detail error: {e}")
return None
async def create_comment(
self,
news_id: str,
content: str,
session_token: str
) -> Optional[Dict[str, Any]]:
"""
创建评论
:param news_id: 新闻 ID
:param content: 评论内容
:param session_token: 会话 Token
:return: 评论结果
"""
try:
async with self._get_client(session_token) as client:
response = await client.post(
"/api/comment/create", # 实际接口路径需根据 doc.html 调整
json={"newsId": news_id, "content": content}
)
if response.status_code == 200:
data = response.json()
logger.info(f"Comment created for news: {news_id}")
return data
else:
logger.error(f"Create comment failed: {response.status_code} - {response.text}")
return None
except Exception as e:
logger.error(f"Create comment error: {e}")
return None
async def create_reply(
self,
comment_id: str,
content: str,
session_token: str
) -> Optional[Dict[str, Any]]:
"""
创建回复
:param comment_id: 评论 ID
:param content: 回复内容
:param session_token: 会话 Token
:return: 回复结果
"""
try:
async with self._get_client(session_token) as client:
response = await client.post(
"/api/reply/create", # 实际接口路径需根据 doc.html 调整
json={"commentId": comment_id, "content": content}
)
if response.status_code == 200:
data = response.json()
logger.info(f"Reply created for comment: {comment_id}")
return data
else:
logger.error(f"Create reply failed: {response.status_code}")
return None
except Exception as e:
logger.error(f"Create reply error: {e}")
return None
async def like_comment(
self,
comment_id: str,
session_token: str
) -> Optional[Dict[str, Any]]:
"""
点赞评论
:param comment_id: 评论 ID
:param session_token: 会话 Token
:return: 点赞结果
"""
try:
async with self._get_client(session_token) as client:
response = await client.post(f"/api/comment/{comment_id}/like")
if response.status_code == 200:
data = response.json()
logger.info(f"Comment liked: {comment_id}")
return data
else:
logger.error(f"Like comment failed: {response.status_code}")
return None
except Exception as e:
logger.error(f"Like comment error: {e}")
return None
async def favorite_news(
self,
news_id: str,
session_token: str
) -> Optional[Dict[str, Any]]:
"""
收藏新闻
:param news_id: 新闻 ID
:param session_token: 会话 Token
:return: 收藏结果
"""
try:
async with self._get_client(session_token) as client:
response = await client.post(f"/api/news/{news_id}/favorite")
if response.status_code == 200:
data = response.json()
logger.info(f"News favorited: {news_id}")
return data
else:
logger.error(f"Favorite news failed: {response.status_code}")
return None
except Exception as e:
logger.error(f"Favorite news error: {e}")
return None
async def share_news(
self,
news_id: str,
session_token: str
) -> Optional[Dict[str, Any]]:
"""
转发新闻
:param news_id: 新闻 ID
:param session_token: 会话 Token
:return: 转发结果
"""
try:
async with self._get_client(session_token) as client:
response = await client.post(f"/api/news/{news_id}/share")
if response.status_code == 200:
data = response.json()
logger.info(f"News shared: {news_id}")
return data
else:
logger.error(f"Share news failed: {response.status_code}")
return None
except Exception as e:
logger.error(f"Share news error: {e}")
return None
async def get_comments(
self,
news_id: str,
page: int = 1,
page_size: int = 20
) -> Optional[List[Dict[str, Any]]]:
"""
获取新闻评论列表
:param news_id: 新闻 ID
:param page: 页码
:param page_size: 每页数量
:return: 评论列表
"""
try:
async with self._get_client() as client:
params = {"page": page, "pageSize": page_size}
response = await client.get(
f"/api/news/{news_id}/comments",
params=params
)
if response.status_code == 200:
data = response.json()
return data.get("data", [])
else:
logger.error(f"Get comments failed: {response.status_code}")
return None
except Exception as e:
logger.error(f"Get comments error: {e}")
return None
# 创建全局服务实例
huihui_api_service = HuihuiAPIService()

View File

@@ -0,0 +1,408 @@
"""
互动执行服务
"""
import logging
import random
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from app.models.virtual_user import VirtualUser, ActivityLevel, UserStatus
from app.models.interaction import InteractionRecord, InteractionType, InteractionStatus
from app.models.token_usage import TokenUsage
from app.models.news_cache import NewsCache
from app.services.huihui_api_service import huihui_api_service
from app.services.ai_service import ai_service
from app.core.config import settings
logger = logging.getLogger(__name__)
class InteractionService:
"""互动执行服务类"""
def __init__(self, db: Session):
self.db = db
async def execute_interaction(
self,
virtual_user_id: int,
interaction_type: Optional[InteractionType] = None,
news_id: Optional[str] = None
) -> Optional[InteractionRecord]:
"""
执行单次互动
:param virtual_user_id: 虚拟用户 ID
:param interaction_type: 互动类型(不传则随机)
:param news_id: 新闻 ID不传则随机选择
:return: 互动记录
"""
# 获取虚拟用户
user = self.db.query(VirtualUser).filter(VirtualUser.id == virtual_user_id).first()
if not user:
logger.error(f"Virtual user not found: {virtual_user_id}")
return None
# 检查用户状态
if user.status != UserStatus.ACTIVE:
logger.warning(f"Virtual user is not active: {virtual_user_id}")
return None
# 检查是否已登录
if not user.is_logged_in or not user.session_token:
logger.warning(f"Virtual user not logged in: {virtual_user_id}")
# TODO: 自动登录
return None
# 检查今日限额
if not self._check_daily_limit(user, interaction_type):
logger.warning(f"Daily limit reached for user {virtual_user_id}")
return None
# 选择新闻
if not news_id:
news_id = await self._select_news(user)
if not news_id:
logger.warning("No news available for interaction")
return None
# 获取新闻详情
news = self.db.query(NewsCache).filter(NewsCache.news_id == news_id).first()
if not news:
# 从 API 获取
news_detail = await huihui_api_service.get_news_detail(news_id)
if news_detail:
news = self._cache_news(news_detail)
if not news:
logger.error(f"Cannot get news detail: {news_id}")
return None
# 确定互动类型
if not interaction_type:
interaction_type = self._random_interaction_type()
# 创建互动记录
record = InteractionRecord(
virtual_user_id=virtual_user_id,
news_id=news_id,
news_title=news.title if news else "",
interaction_type=interaction_type,
status=InteractionStatus.PENDING
)
self.db.add(record)
self.db.commit()
self.db.refresh(record)
try:
# 执行互动
if interaction_type == InteractionType.COMMENT:
result = await self._execute_comment(user, news, record)
elif interaction_type == InteractionType.REPLY:
result = await self._execute_reply(user, news, record)
elif interaction_type == InteractionType.LIKE:
result = await self._execute_like(user, news, record)
elif interaction_type == InteractionType.FAVORITE:
result = await self._execute_favorite(user, news, record)
elif interaction_type == InteractionType.SHARE:
result = await self._execute_share(user, news, record)
else:
logger.error(f"Unknown interaction type: {interaction_type}")
return None
if result:
record.status = InteractionStatus.SUCCESS
record.api_response = str(result)
# 更新用户统计
user.total_interactions += 1
if interaction_type == InteractionType.COMMENT:
user.today_comments += 1
elif interaction_type == InteractionType.REPLY:
user.today_replies += 1
user.last_interaction_time = datetime.now()
self.db.commit()
logger.info(f"Interaction executed successfully: user={user.id}, type={interaction_type}")
return record
else:
record.status = InteractionStatus.FAILED
record.error_message = "API call failed"
self.db.commit()
return None
except Exception as e:
logger.error(f"Execute interaction error: {e}")
record.status = InteractionStatus.FAILED
record.error_message = str(e)
self.db.commit()
return None
async def _execute_comment(
self,
user: VirtualUser,
news: NewsCache,
record: InteractionRecord
) -> Optional[Dict[str, Any]]:
"""执行评论"""
# AI 生成评论内容
ai_result = await ai_service.generate_comment(
news_content=news.content or news.summary or news.title,
writing_style=user.writing_style or "普通",
persona_description=user.persona_description
)
if not ai_result or not ai_result.get("content"):
logger.error("AI generate comment failed")
return None
# 记录 Token 使用
self._record_token_usage(
virtual_user_id=user.id,
interaction_id=record.id,
tokens_used=ai_result.get("tokens_used", 0),
ai_model=ai_result.get("model", "unknown"),
action_type="generate_comment",
tokens_prompt=ai_result.get("tokens_prompt", 0),
tokens_completion=ai_result.get("tokens_completion", 0)
)
# 调用接口提交评论
result = await huihui_api_service.create_comment(
news_id=news.news_id,
content=ai_result["content"],
session_token=user.session_token
)
if result:
record.content = ai_result["content"]
record.tokens_used = ai_result.get("tokens_used", 0)
record.ai_model_used = ai_result.get("model")
return result
async def _execute_reply(
self,
user: VirtualUser,
news: NewsCache,
record: InteractionRecord
) -> Optional[Dict[str, Any]]:
"""执行回复"""
# 获取评论列表
comments = await huihui_api_service.get_comments(news_id=news.news_id)
if not comments or len(comments) == 0:
logger.warning(f"No comments available for news {news.news_id}")
return None
# 随机选择一条评论进行回复
target_comment = random.choice(comments)
record.target_comment_id = target_comment.get("id")
# AI 生成回复内容
ai_result = await ai_service.generate_reply(
original_comment=target_comment.get("content", ""),
news_content=news.content or news.summary or news.title,
writing_style=user.writing_style or "普通",
persona_description=user.persona_description
)
if not ai_result or not ai_result.get("content"):
logger.error("AI generate reply failed")
return None
# 记录 Token 使用
self._record_token_usage(
virtual_user_id=user.id,
interaction_id=record.id,
tokens_used=ai_result.get("tokens_used", 0),
ai_model=ai_result.get("model", "unknown"),
action_type="generate_reply"
)
# 调用接口提交回复
result = await huihui_api_service.create_reply(
comment_id=target_comment.get("id"),
content=ai_result["content"],
session_token=user.session_token
)
if result:
record.content = ai_result["content"]
record.tokens_used = ai_result.get("tokens_used", 0)
return result
async def _execute_like(
self,
user: VirtualUser,
news: NewsCache,
record: InteractionRecord
) -> Optional[Dict[str, Any]]:
"""执行点赞"""
# 获取评论列表
comments = await huihui_api_service.get_comments(news_id=news.news_id)
if not comments or len(comments) == 0:
logger.warning(f"No comments available for like")
return None
# 随机选择一条评论点赞
target_comment = random.choice(comments)
record.target_comment_id = target_comment.get("id")
result = await huihui_api_service.like_comment(
comment_id=target_comment.get("id"),
session_token=user.session_token
)
return result
async def _execute_favorite(
self,
user: VirtualUser,
news: NewsCache,
record: InteractionRecord
) -> Optional[Dict[str, Any]]:
"""执行收藏"""
result = await huihui_api_service.favorite_news(
news_id=news.news_id,
session_token=user.session_token
)
return result
async def _execute_share(
self,
user: VirtualUser,
news: NewsCache,
record: InteractionRecord
) -> Optional[Dict[str, Any]]:
"""执行转发"""
result = await huihui_api_service.share_news(
news_id=news.news_id,
session_token=user.session_token
)
return result
def _check_daily_limit(
self,
user: VirtualUser,
interaction_type: Optional[InteractionType]
) -> bool:
"""检查每日限额"""
today = datetime.now().date()
# 统计今日互动
today_records = self.db.query(InteractionRecord).filter(
and_(
InteractionRecord.virtual_user_id == user.id,
func.date(InteractionRecord.execution_time) == today,
InteractionRecord.status == InteractionStatus.SUCCESS
)
).all()
today_comments = sum(1 for r in today_records if r.interaction_type == InteractionType.COMMENT)
today_replies = sum(1 for r in today_records if r.interaction_type == InteractionType.REPLY)
# 检查评论限额
if interaction_type == InteractionType.COMMENT:
if today_comments >= settings.MAX_COMMENTS_PER_USER_PER_DAY:
return False
# 检查回复限额
if interaction_type == InteractionType.REPLY:
if today_replies >= settings.MAX_REPLIES_PER_USER_PER_DAY:
return False
return True
def _random_interaction_type(self) -> InteractionType:
"""随机选择互动类型"""
rand = random.random()
# 根据概率决定互动类型
if rand < settings.LIKE_PROBABILITY:
return InteractionType.LIKE
elif rand < settings.LIKE_PROBABILITY + settings.FAVORITE_PROBABILITY:
return InteractionType.FAVORITE
elif rand < settings.LIKE_PROBABILITY + settings.FAVORITE_PROBABILITY + settings.SHARE_PROBABILITY:
return InteractionType.SHARE
else:
return InteractionType.COMMENT
async def _select_news(self, user: VirtualUser) -> Optional[str]:
"""选择新闻"""
# 优先选择未互动过的新闻
cached_news = self.db.query(NewsCache).order_by(
NewsCache.created_at.desc()
).limit(50).all()
if not cached_news:
# 从 API 获取
news_list = await huihui_api_service.get_news_list(page=1, page_size=20)
if news_list:
for news_data in news_list:
self._cache_news(news_data)
cached_news = self.db.query(NewsCache).order_by(
NewsCache.created_at.desc()
).limit(50).all()
if not cached_news:
return None
# 随机选择一篇
return random.choice(cached_news).news_id
def _cache_news(self, news_data: Dict[str, Any]) -> Optional[NewsCache]:
"""缓存新闻"""
news = NewsCache(
news_id=str(news_data.get("id")),
title=news_data.get("title", ""),
summary=news_data.get("summary", ""),
content=news_data.get("content", ""),
source=news_data.get("source", ""),
author=news_data.get("author", ""),
category=news_data.get("category", "")
)
self.db.add(news)
self.db.commit()
self.db.refresh(news)
return news
def _record_token_usage(
self,
virtual_user_id: int,
interaction_id: int,
tokens_used: int,
ai_model: str,
action_type: str,
tokens_prompt: int = 0,
tokens_completion: int = 0
):
"""记录 Token 使用"""
usage = TokenUsage(
virtual_user_id=virtual_user_id,
interaction_id=interaction_id,
tokens_used=tokens_used,
tokens_prompt=tokens_prompt,
tokens_completion=tokens_completion,
ai_model=ai_model,
action_type=action_type
)
self.db.add(usage)
self.db.commit()
logger.info(f"Token usage recorded: {tokens_used} tokens for user {virtual_user_id}")
# 工厂函数
def get_interaction_service(db: Session) -> InteractionService:
"""获取互动服务实例"""
return InteractionService(db)

View File

@@ -0,0 +1,215 @@
"""
定时任务调度服务
基于 APScheduler 实现
"""
import logging
import random
import asyncio
from typing import Optional, List
from datetime import datetime, time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy.orm import Session
from app.models.virtual_user import VirtualUser, ActivityLevel, UserStatus
from app.models.base import get_db, SessionLocal
from app.services.interaction_service import InteractionService
from app.core.config import settings
logger = logging.getLogger(__name__)
class SchedulerService:
"""定时任务调度服务类"""
def __init__(self):
self.scheduler = AsyncIOScheduler()
self.is_running = False
self._current_job = None
def start(self):
"""启动调度器"""
if not self.is_running:
self.scheduler.start()
self.is_running = True
logger.info("Scheduler started")
def stop(self):
"""停止调度器"""
if self.is_running:
self.scheduler.shutdown()
self.is_running = False
logger.info("Scheduler stopped")
def add_interaction_task(self):
"""添加互动任务"""
# 在活动时间段内,每隔随机时间执行一次互动
# 由于 APScheduler 不支持随机间隔,我们使用固定间隔但通过概率控制执行
# 每 5 分钟检查一次
trigger = IntervalTrigger(minutes=5)
self.scheduler.add_job(
self._execute_random_interaction,
trigger=trigger,
id="random_interaction",
name="Random Interaction Task",
replace_existing=True
)
logger.info("Interaction task added")
def remove_interaction_task(self):
"""移除互动任务"""
try:
self.scheduler.remove_job("random_interaction")
logger.info("Interaction task removed")
except Exception as e:
logger.warning(f"Remove interaction task error: {e}")
async def _execute_random_interaction(self):
"""执行随机互动任务"""
# 检查是否在活动时间段内
now = datetime.now()
current_hour = now.hour
if current_hour < settings.TASK_START_HOUR or current_hour > settings.TASK_END_HOUR:
logger.debug(f"Outside activity hours: {current_hour}")
return
# 随机决定是否执行(通过随机间隔模拟)
if random.random() > 0.5: # 50% 概率执行
logger.debug("Skip this round")
return
logger.info("Executing random interaction task")
# 获取数据库会话
db = SessionLocal()
try:
# 获取所有活跃的虚拟用户
users = db.query(VirtualUser).filter(
VirtualUser.status == UserStatus.ACTIVE,
VirtualUser.is_logged_in == True
).all()
if not users:
logger.debug("No active logged-in users")
return
# 随机选择一个用户
user = random.choice(users)
# 检查用户活跃度
if not self._should_user_interact(user):
logger.debug(f"User {user.id} should not interact now")
return
# 执行互动
interaction_service = InteractionService(db)
await interaction_service.execute_interaction(virtual_user_id=user.id)
except Exception as e:
logger.error(f"Execute random interaction error: {e}")
finally:
db.close()
def _should_user_interact(self, user: VirtualUser) -> bool:
"""根据活跃度判断用户是否应该互动"""
# 根据活跃度决定互动概率
if user.activity_level == ActivityLevel.HIGH:
# 高活跃度80% 概率
return random.random() < 0.8
elif user.activity_level == ActivityLevel.MEDIUM:
# 中活跃度50% 概率
return random.random() < 0.5
else:
# 低活跃度30% 概率
return random.random() < 0.3
def add_login_task(self, hour: int = 8, minute: int = 0):
"""添加每日登录任务"""
trigger = CronTrigger(hour=hour, minute=minute)
self.scheduler.add_job(
self._auto_login_users,
trigger=trigger,
id="daily_login",
name="Daily Auto Login",
replace_existing=True
)
logger.info(f"Daily login task added at {hour:02d}:{minute:02d}")
async def _auto_login_users(self):
"""自动登录所有活跃用户"""
db = SessionLocal()
try:
from app.services.huihui_api_service import huihui_api_service
users = db.query(VirtualUser).filter(
VirtualUser.status == UserStatus.ACTIVE
).all()
for user in users:
try:
# 调用登录接口
result = await huihui_api_service.login(user.username, user.password)
if result and result.get("token"):
user.is_logged_in = True
user.session_token = result["token"]
# TODO: 设置 token 过期时间
logger.info(f"Auto login success: {user.username}")
else:
logger.warning(f"Auto login failed: {user.username}")
except Exception as e:
logger.error(f"Auto login error for {user.username}: {e}")
db.commit()
except Exception as e:
logger.error(f"Auto login task error: {e}")
db.rollback()
finally:
db.close()
def reset_daily_counters(self, hour: int = 0, minute: int = 1):
"""添加每日计数器重置任务"""
trigger = CronTrigger(hour=hour, minute=minute)
self.scheduler.add_job(
self._reset_daily_counters,
trigger=trigger,
id="reset_daily_counters",
name="Reset Daily Counters",
replace_existing=True
)
logger.info(f"Daily reset task added at {hour:02d}:{minute:02d}")
def _reset_daily_counters(self):
"""重置每日计数器"""
db = SessionLocal()
try:
# 重置所有用户的今日计数
db.query(VirtualUser).update({
VirtualUser.today_comments: 0,
VirtualUser.today_replies: 0
})
db.commit()
logger.info("Daily counters reset")
except Exception as e:
logger.error(f"Reset daily counters error: {e}")
db.rollback()
finally:
db.close()
# 创建全局服务实例
scheduler_service = SchedulerService()

View File

@@ -0,0 +1,173 @@
"""
Token 使用统计服务
"""
import logging
from typing import Dict, Any, List
from datetime import datetime, timedelta, date
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, extract
from app.models.token_usage import TokenUsage
from app.core.config import settings
logger = logging.getLogger(__name__)
class TokenService:
"""Token 统计服务类"""
def __init__(self, db: Session):
self.db = db
def get_today_usage(self) -> int:
"""获取今日 Token 使用量"""
today = date.today()
result = self.db.query(func.sum(TokenUsage.tokens_used)).filter(
func.date(TokenUsage.usage_date) == today
).scalar()
return result or 0
def get_yesterday_usage(self) -> int:
"""获取昨日 Token 使用量"""
yesterday = date.today() - timedelta(days=1)
result = self.db.query(func.sum(TokenUsage.tokens_used)).filter(
func.date(TokenUsage.usage_date) == yesterday
).scalar()
return result or 0
def get_month_usage(self, year: Optional[int] = None, month: Optional[int] = None) -> int:
"""获取当月 Token 使用量"""
if not year or not month:
now = datetime.now()
year = now.year
month = now.month
result = self.db.query(func.sum(TokenUsage.tokens_used)).filter(
and_(
extract('year', TokenUsage.usage_date) == year,
extract('month', TokenUsage.usage_date) == month
)
).scalar()
return result or 0
def get_remaining_tokens(self) -> int:
"""获取今日剩余 Token"""
today_used = self.get_today_usage()
remaining = settings.MAX_TOKENS_PER_DAY - today_used
return max(0, remaining)
def get_daily_usages(self, days: int = 30) -> List[Dict[str, Any]]:
"""
获取每日 Token 使用(用于图表)
:param days: 天数
:return: 每日使用列表
"""
end_date = date.today()
start_date = end_date - timedelta(days=days - 1)
results = self.db.query(
func.date(TokenUsage.usage_date).label('usage_date'),
func.sum(TokenUsage.tokens_used).label('total_tokens')
).filter(
and_(
func.date(TokenUsage.usage_date) >= start_date,
func.date(TokenUsage.usage_date) <= end_date
)
).group_by(
func.date(TokenUsage.usage_date)
).order_by(
func.date(TokenUsage.usage_date)
).all()
# 转换为字典列表
usage_dict = {str(row.usage_date): row.total_tokens for row in results}
# 填充缺失的日期
daily_usages = []
current_date = start_date
while current_date <= end_date:
date_str = str(current_date)
tokens = usage_dict.get(date_str, 0)
daily_usages.append({
"date": date_str,
"tokens": tokens
})
current_date += timedelta(days=1)
return daily_usages
def get_monthly_usages(self, months: int = 12) -> List[Dict[str, Any]]:
"""
获取每月 Token 使用(用于图表)
:param months: 月数
:return: 每月使用列表
"""
now = datetime.now()
results = []
for i in range(months):
# 计算月份
month_offset = months - 1 - i
target_date = now - timedelta(days=30 * month_offset)
year = target_date.year
month = target_date.month
# 查询该月的使用量
usage = self.get_month_usage(year, month)
results.append({
"month": f"{year}-{month:02d}",
"tokens": usage
})
return results
def get_user_token_usage(
self,
user_id: int,
days: int = 30
) -> List[Dict[str, Any]]:
"""
获取指定用户的 Token 使用
:param user_id: 用户 ID
:param days: 天数
:return: 每日使用列表
"""
end_date = date.today()
start_date = end_date - timedelta(days=days - 1)
results = self.db.query(
func.date(TokenUsage.usage_date).label('usage_date'),
func.sum(TokenUsage.tokens_used).label('total_tokens')
).filter(
and_(
TokenUsage.virtual_user_id == user_id,
func.date(TokenUsage.usage_date) >= start_date,
func.date(TokenUsage.usage_date) <= end_date
)
).group_by(
func.date(TokenUsage.usage_date)
).order_by(
func.date(TokenUsage.usage_date)
).all()
return [
{"date": str(row.usage_date), "tokens": row.total_tokens}
for row in results
]
def check_token_limit_exceeded(self) -> bool:
"""检查是否超出 Token 限额"""
today_used = self.get_today_usage()
return today_used >= settings.MAX_TOKENS_PER_DAY
# 工厂函数
def get_token_service(db: Session) -> TokenService:
"""获取 Token 服务实例"""
return TokenService(db)

View File

@@ -0,0 +1,361 @@
"""
虚拟用户管理服务
"""
import logging
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import and_, func, Date
from app.models.virtual_user import VirtualUser, VirtualUserPersona, ActivityLevel, UserStatus
from app.models.interaction import InteractionRecord, InteractionType
from app.services.ai_service import ai_service
from app.core.config import settings
logger = logging.getLogger(__name__)
class VirtualUserService:
"""虚拟用户服务类"""
# 预设写作风格库
WRITING_STYLES = [
"幽默风趣",
"严肃理性",
"文艺清新",
"吐槽犀利",
"感性温暖",
"客观中立",
"激情澎湃",
"冷静分析",
"活泼可爱",
"深沉内敛"
]
# 昵称前缀和后缀
NICKNAME_PREFIXES = ["清风", "星辰", "云端", "晨曦", "暮色", "流年", "初心", "远方"]
NICKNAME_SUFFIXES = ["行者", "旅人", "追梦", "时光", "记忆", "印象", "故事", "传奇"]
def __init__(self, db: Session):
self.db = db
def get_user_by_id(self, user_id: int) -> Optional[VirtualUser]:
"""根据 ID 获取用户"""
return self.db.query(VirtualUser).filter(VirtualUser.id == user_id).first()
def get_user_by_username(self, username: str) -> Optional[VirtualUser]:
"""根据用户名获取用户"""
return self.db.query(VirtualUser).filter(VirtualUser.username == username).first()
def get_users(
self,
page: int = 1,
page_size: int = 20,
status: Optional[UserStatus] = None,
search: Optional[str] = None
) -> Dict[str, Any]:
"""
获取用户列表
:param page: 页码
:param page_size: 每页数量
:param status: 状态筛选
:param search: 搜索关键词
:return: 用户列表和总数
"""
query = self.db.query(VirtualUser)
if status:
query = query.filter(VirtualUser.status == status)
if search:
query = query.filter(
or_(
VirtualUser.nickname.like(f"%{search}%"),
VirtualUser.username.like(f"%{search}%")
)
)
total = query.count()
users = query.order_by(VirtualUser.created_at.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {"total": total, "items": users}
def create_user(
self,
username: str,
password: str,
nickname: str,
writing_style: Optional[str] = None,
activity_level: ActivityLevel = ActivityLevel.MEDIUM,
avatar_url: Optional[str] = None,
persona_description: Optional[str] = None
) -> Optional[VirtualUser]:
"""
创建虚拟用户
:param username: 用户名
:param password: 密码
:param nickname: 昵称
:param writing_style: 写作风格
:param activity_level: 活跃度
:param avatar_url: 头像 URL
:param persona_description: 人格描述
:return: 创建的用户
"""
# 检查用户名是否已存在
existing = self.get_user_by_username(username)
if existing:
logger.error(f"Username already exists: {username}")
return None
user = VirtualUser(
username=username,
password=password, # TODO: 加密存储
nickname=nickname,
writing_style=writing_style or self._random_writing_style(),
activity_level=activity_level,
avatar_url=avatar_url or self._generate_avatar_url(),
persona_description=persona_description
)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
logger.info(f"Virtual user created: {username}")
return user
def generate_users(
self,
count: int,
writing_styles: Optional[List[str]] = None,
activity_levels: Optional[List[ActivityLevel]] = None,
generate_persona: bool = True
) -> List[VirtualUser]:
"""
批量生成虚拟用户
:param count: 生成数量
:param writing_styles: 写作风格列表
:param activity_levels: 活跃度级别列表
:param generate_persona: 是否生成 AI 人格描述
:return: 生成的用户列表
"""
import random
styles = writing_styles or self.WRITING_STYLES
levels = activity_levels or [ActivityLevel.LOW, ActivityLevel.MEDIUM, ActivityLevel.HIGH]
created_users = []
for i in range(count):
# 生成唯一用户名
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
username = f"user_{timestamp}_{i}"
# 随机生成昵称
prefix = random.choice(self.NICKNAME_PREFIXES)
suffix = random.choice(self.NICKNAME_SUFFIXES)
nickname = f"{prefix}{suffix}{random.randint(100, 999)}"
# 随机密码
password = f"pwd_{random.randint(100000, 999999)}"
# 随机写作风格
writing_style = random.choice(styles)
# 随机活跃度
activity_level = random.choice(levels)
# 生成头像
avatar_url = self._generate_avatar_url()
# AI 生成人格描述
persona_description = None
if generate_persona:
persona_description = self._generate_persona_description(
writing_style,
activity_level
)
user = self.create_user(
username=username,
password=password,
nickname=nickname,
writing_style=writing_style,
activity_level=activity_level,
avatar_url=avatar_url,
persona_description=persona_description
)
if user:
created_users.append(user)
logger.info(f"Generated {len(created_users)} virtual users")
return created_users
def _generate_persona_description(
self,
writing_style: str,
activity_level: ActivityLevel
) -> str:
"""AI 生成人格描述"""
import asyncio
prompt = f"""请为一位虚拟用户生成人格描述,要求:
- 写作风格:{writing_style}
- 活跃度:{activity_level.value}
请用 50-100 字描述这个人的性格特点、兴趣爱好、说话方式等。直接输出描述内容。"""
try:
# 同步调用异步方法
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(ai_service._call_ai_api(prompt))
loop.close()
if result and result.get("content"):
return result["content"]
except Exception as e:
logger.error(f"Generate persona description error: {e}")
return f"这是一位{writing_style}的虚拟用户,活跃度{activity_level.value}"
def _random_writing_style(self) -> str:
"""随机选择写作风格"""
import random
return random.choice(self.WRITING_STYLES)
def _generate_avatar_url(self) -> str:
"""生成随机头像 URL使用第三方头像 API"""
import random
# 使用 DiceBear 头像 API
seed = f"avatar_{datetime.now().timestamp()}_{random.randint(1000, 9999)}"
return f"https://api.dicebear.com/7.x/avataaars/svg?seed={seed}"
def update_user(
self,
user_id: int,
**kwargs
) -> Optional[VirtualUser]:
"""更新用户信息"""
user = self.get_user_by_id(user_id)
if not user:
return None
for key, value in kwargs.items():
if hasattr(user, key) and value is not None:
setattr(user, key, value)
self.db.commit()
self.db.refresh(user)
return user
def delete_user(self, user_id: int) -> bool:
"""删除用户"""
user = self.get_user_by_id(user_id)
if not user:
return False
self.db.delete(user)
self.db.commit()
logger.info(f"Virtual user deleted: {user_id}")
return True
def import_users_from_excel(
self,
users_data: List[Dict[str, Any]],
generate_persona: bool = True
) -> Dict[str, Any]:
"""
从 Excel 导入虚拟用户
:param users_data: 用户数据列表
:param generate_persona: 是否生成 AI 人格描述
:return: 导入结果
"""
success_count = 0
failed_count = 0
created_users = []
for user_data in users_data:
try:
username = user_data.get("username")
password = user_data.get("password")
nickname = user_data.get("nickname", "")
if not username or not password:
logger.warning(f"Missing username or password: {user_data}")
failed_count += 1
continue
# 如果昵称为空,生成一个
if not nickname:
nickname = f"用户{username}"
writing_style = user_data.get("writing_style")
activity_level_str = user_data.get("activity_level", "medium")
# 转换活跃度枚举
try:
activity_level = ActivityLevel(activity_level_str.lower())
except ValueError:
activity_level = ActivityLevel.MEDIUM
user = self.create_user(
username=username,
password=password,
nickname=nickname,
writing_style=writing_style,
activity_level=activity_level
)
if user:
success_count += 1
created_users.append(user)
else:
failed_count += 1
except Exception as e:
logger.error(f"Import user error: {e}")
failed_count += 1
return {
"success_count": success_count,
"failed_count": failed_count,
"created_users": created_users
}
def get_user_stats(self, user_id: int) -> Dict[str, Any]:
"""获取用户统计信息"""
user = self.get_user_by_id(user_id)
if not user:
return {}
today = datetime.now().date()
# 统计今日互动
today_interactions = self.db.query(InteractionRecord).filter(
and_(
InteractionRecord.virtual_user_id == user_id,
func.date(InteractionRecord.execution_time) == today
)
).all()
today_comments = sum(1 for i in today_interactions if i.interaction_type == InteractionType.COMMENT)
today_replies = sum(1 for i in today_interactions if i.interaction_type == InteractionType.REPLY)
return {
"user_id": user_id,
"nickname": user.nickname,
"total_interactions": user.total_interactions,
"today_comments": today_comments,
"today_replies": today_replies,
"last_interaction_time": user.last_interaction_time
}
# 工厂函数
def get_virtual_user_service(db: Session) -> VirtualUserService:
"""获取虚拟用户服务实例"""
return VirtualUserService(db)

35
backend/requirements.txt Normal file
View File

@@ -0,0 +1,35 @@
# Web Framework
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# Database
sqlalchemy==2.0.25
alembic==1.13.1
pymysql==1.1.0
# AI Models
openai==1.10.0
zhipuai==2.0.1
# Utilities
pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
httpx==0.26.0
apscheduler==3.10.4
# Excel Support
openpyxl==3.1.2
pandas==2.1.4
# Security
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
# Logging
loguru==0.7.2
# Testing
pytest==7.4.4
pytest-asyncio==0.23.3

0
data/logs/.gitkeep Normal file
View File

0
data/uploads/.gitkeep Normal file
View File

15
docker/mysql/init.sql Normal file
View File

@@ -0,0 +1,15 @@
-- 初始化数据库表结构(如果使用 SQLAlchemy 自动创建,此文件可选)
-- 创建扩展
CREATE DATABASE IF NOT EXISTS huihui_ai_bot
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE huihui_ai_bot;
-- 插入初始系统配置数据
INSERT INTO system_configs (config_key, config_value, config_type, description, is_active) VALUES
('schedule_config', '{"task_start_hour": 9, "task_end_hour": 22, "task_interval_min": 10, "task_interval_max": 30}', 'schedule', '定时任务配置', 1),
('limit_config', '{"max_tokens_per_day": 10000, "max_comments_per_user_per_day": 20, "max_replies_per_user_per_day": 10}', 'limit', '限额配置', 1),
('probability_config', '{"like_probability": 0.8, "favorite_probability": 0.5, "share_probability": 0.3}', 'probability', '概率配置', 1)
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value);

41
docker/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,41 @@
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# API 代理到后端服务
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持(如果需要)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# 静态资源缓存
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
}

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>会会虚拟用户 AI 互动系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

25
frontend/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "huihui-ai-bot-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"element-plus": "^2.5.0",
"axios": "^1.6.5",
"echarts": "^5.4.3",
"@element-plus/icons-vue": "^2.3.1",
"dayjs": "^1.11.10"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.10",
"sass": "^1.69.5"
}
}

22
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,22 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup>
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
width: 100%;
height: 100vh;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
}
</style>

56
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,56 @@
import request from './request'
// 仪表盘 API
export const dashboardApi = {
getStats: () => request.get('/dashboard'),
getTokenStats: () => request.get('/dashboard/token/stats'),
getDailyUsage: (days = 30) => request.get(`/dashboard/token/daily?days=${days}`),
getMonthlyUsage: (months = 12) => request.get(`/dashboard/token/monthly?months=${months}`)
}
// 虚拟用户 API
export const virtualUserApi = {
getList: (params) => request.get('/virtual-users', { params }),
getById: (id) => request.get(`/virtual-users/${id}`),
create: (data) => request.post('/virtual-users', data),
generate: (data) => request.post('/virtual-users/generate', data),
update: (id, data) => request.put(`/virtual-users/${id}`, data),
delete: (id) => request.delete(`/virtual-users/${id}`),
import: (file, generatePersona = true) => {
const formData = new FormData()
formData.append('file', file)
return request.post('/virtual-users/import', formData, {
params: { generate_persona: generatePersona }
})
},
getStats: (id) => request.get(`/virtual-users/${id}/stats`)
}
// 互动记录 API
export const interactionApi = {
getList: (params) => request.get('/interactions', { params }),
execute: (data) => request.post('/interactions/execute', data),
retry: (id) => request.post(`/interactions/retry/${id}`)
}
// AI 模型 API
export const aiModelApi = {
getList: () => request.get('/ai-models'),
getById: (id) => request.get(`/ai-models/${id}`),
create: (data) => request.post('/ai-models', data),
update: (id, data) => request.put(`/ai-models/${id}`, data),
delete: (id) => request.delete(`/ai-models/${id}`),
test: (data) => request.post('/ai-models/test', data)
}
// 系统配置 API
export const systemApi = {
getSchedule: () => request.get('/system/schedule'),
getLimits: () => request.get('/system/limits'),
getProbabilities: () => request.get('/system/probabilities'),
updateSchedule: (data) => request.put('/system/schedule', data),
updateLimits: (data) => request.put('/system/limits', data),
startScheduler: () => request.post('/system/scheduler/start'),
stopScheduler: () => request.post('/system/scheduler/stop'),
getSchedulerStatus: () => request.get('/system/scheduler/status')
}

View File

@@ -0,0 +1,60 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
// 创建 axios 实例
const request = axios.create({
baseURL: '/api/v1',
timeout: 30000
})
// 请求拦截器
request.interceptors.request.use(
config => {
// TODO: 添加 token
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
response => {
return response.data
},
error => {
let message = '请求失败'
if (error.response) {
switch (error.response.status) {
case 400:
message = error.response.data.detail || '请求参数错误'
break
case 401:
message = '未授权,请登录'
break
case 403:
message = '拒绝访问'
break
case 404:
message = '请求地址出错'
break
case 500:
message = '服务器内部错误'
break
default:
message = error.response.data.detail || message
}
} else if (error.message.includes('timeout')) {
message = '请求超时'
} else if (error.message.includes('Network')) {
message = '网络连接失败'
}
ElMessage.error(message)
return Promise.reject(error)
}
)
export default request

25
frontend/src/main.js Normal file
View File

@@ -0,0 +1,25 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
app.mount('#app')

View File

@@ -0,0 +1,48 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '控制台' }
},
{
path: '/virtual-users',
name: 'VirtualUsers',
component: () => import('@/views/VirtualUsers.vue'),
meta: { title: '虚拟用户管理' }
},
{
path: '/interactions',
name: 'Interactions',
component: () => import('@/views/Interactions.vue'),
meta: { title: '互动记录' }
},
{
path: '/ai-models',
name: 'AIModels',
component: () => import('@/views/AIModels.vue'),
meta: { title: 'AI 模型配置' }
},
{
path: '/settings',
name: 'Settings',
component: () => import('@/views/Settings.vue'),
meta: { title: '系统设置' }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
if (to.meta.title) {
document.title = `${to.meta.title} - 会会虚拟用户 AI 互动系统`
}
next()
})
export default router

View File

@@ -0,0 +1,275 @@
<template>
<div class="ai-models">
<el-card>
<template #header>
<div class="card-header">
<span>AI 模型配置</span>
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
添加模型
</el-button>
</div>
</template>
<el-table :data="modelList" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="display_name" label="名称" width="150" />
<el-table-column prop="model_name" label="模型" width="150" />
<el-table-column prop="provider" label="提供商" width="120">
<template #default="{ row }">
<el-tag size="small">{{ getProviderLabel(row.provider) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="temperature" label="温度" width="100" />
<el-table-column prop="max_tokens" label="最大 Token" width="100" />
<el-table-column prop="is_default" label="默认" width="80">
<template #default="{ row }">
<el-tag size="small" :type="row.is_default ? 'success' : 'info'">
{{ row.is_default ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="80">
<template #default="{ row }">
<el-tag size="small" :type="row.is_active ? 'success' : 'info'">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="250">
<template #default="{ row }">
<el-button size="small" @click="handleTest(row)">测试</el-button>
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="showModelDialog"
:title="isEdit ? '编辑模型' : '添加模型'"
width="600px"
>
<el-form :model="modelForm" label-width="120px">
<el-form-item label="显示名称">
<el-input v-model="modelForm.display_name" placeholder="如GPT-3.5" />
</el-form-item>
<el-form-item label="模型名称">
<el-input v-model="modelForm.model_name" placeholder="如gpt-3.5-turbo" :disabled="isEdit" />
</el-form-item>
<el-form-item label="提供商">
<el-select v-model="modelForm.provider" placeholder="请选择" :disabled="isEdit">
<el-option label="OpenAI" value="openai" />
<el-option label="智谱 AI" value="zhipu" />
<el-option label="百度文心" value="baidu" />
<el-option label="阿里通义" value="aliyun" />
</el-select>
</el-form-item>
<el-form-item label="API 地址">
<el-input v-model="modelForm.api_url" placeholder="https://api.openai.com/v1" />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="modelForm.api_key" type="password" show-password placeholder="sk-..." />
</el-form-item>
<el-form-item label="温度">
<el-slider v-model="modelForm.temperature" :min="0" :max="1" :step="0.1" />
</el-form-item>
<el-form-item label="最大 Token 数">
<el-input-number v-model="modelForm.max_tokens" :min="1" :max="4096" />
</el-form-item>
<el-form-item label="设为默认">
<el-switch v-model="modelForm.is_default" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="modelForm.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showModelDialog = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 测试对话框 -->
<el-dialog
v-model="showTestDialog"
title="测试模型"
width="500px"
>
<el-form>
<el-form-item label="测试提示词">
<el-input
v-model="testPrompt"
type="textarea"
:rows="4"
placeholder="请输入测试内容,如:请写一条关于春天的评论"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showTestDialog = false">取消</el-button>
<el-button type="primary" @click="handleRunTest" :loading="testing">测试</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { aiModelApi } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const saving = ref(false)
const testing = ref(false)
const modelList = ref([])
const showAddDialog = ref(false)
const showModelDialog = ref(false)
const showTestDialog = ref(false)
const isEdit = ref(false)
const currentModelId = ref(null)
const testPrompt = ref('')
const modelForm = ref({
display_name: '',
model_name: '',
provider: '',
api_url: '',
api_key: '',
temperature: 0.7,
max_tokens: 1000,
is_default: false,
is_active: true
})
const testData = ref({})
// 加载数据
const loadData = async () => {
loading.value = true
try {
const res = await aiModelApi.getList()
modelList.value = res || []
} catch (error) {
console.error('Load AI models error:', error)
} finally {
loading.value = false
}
}
// 添加/编辑
const handleAdd = () => {
isEdit.value = false
modelForm.value = {
display_name: '',
model_name: '',
provider: '',
api_url: '',
api_key: '',
temperature: 0.7,
max_tokens: 1000,
is_default: false,
is_active: true
}
showModelDialog.value = true
}
const handleEdit = (row) => {
isEdit.value = true
currentModelId.value = row.id
modelForm.value = { ...row }
showModelDialog.value = true
}
// 提交
const handleSubmit = async () => {
saving.value = true
try {
if (isEdit.value) {
await aiModelApi.update(currentModelId.value, modelForm.value)
ElMessage.success('更新成功')
} else {
await aiModelApi.create(modelForm.value)
ElMessage.success('创建成功')
}
showModelDialog.value = false
loadData()
} catch (error) {
console.error('Save model error:', error)
} finally {
saving.value = false
}
}
// 删除
const handleDelete = (row) => {
ElMessageBox.confirm('确定要删除此模型配置吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await aiModelApi.delete(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error) {
console.error('Delete model error:', error)
}
})
}
// 测试
const handleTest = (row) => {
currentModelId.value = row.id
testPrompt.value = '请写一条简短的评论'
showTestDialog.value = true
}
const handleRunTest = async () => {
testing.value = true
try {
const res = await aiModelApi.test({
model_id: currentModelId.value,
test_prompt: testPrompt.value
})
testData.value = res
if (res.success) {
ElMessage.success(`测试成功!消耗 ${res.tokens_used} tokens`)
console.log('Test result:', res.content)
} else {
ElMessage.error(`测试失败:${res.error_message}`)
}
} catch (error) {
console.error('Test model error:', error)
} finally {
testing.value = false
}
}
const getProviderLabel = (provider) => {
const map = { openai: 'OpenAI', zhipu: '智谱 AI', baidu: '百度文心', aliyun: '阿里通义' }
return map[provider] || provider
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.ai-models {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,319 @@
<template>
<div class="dashboard">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>核心指标</span>
<el-button @click="refreshData" :loading="loading" circle>
<el-icon><Refresh /></el-icon>
</el-button>
</div>
</template>
<!-- 核心指标卡片 -->
<el-row :gutter="20">
<el-col :span="6">
<el-statistic title="虚拟用户总数" :value="stats.core_stats?.total_users || 0">
<template #suffix>
<span style="font-size: 14px; color: #909399; margin-left: 8px;">
(启用{{ stats.core_stats?.active_users || 0 }} /
禁用{{ stats.core_stats?.disabled_users || 0 }})
</span>
</template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="今日互动" :value="todayInteractions">
<template #suffix>
<span style="font-size: 14px; color: #909399; margin-left: 8px;">
评论{{ stats.core_stats?.today_comments || 0 }} /
回复{{ stats.core_stats?.today_replies || 0 }}
</span>
</template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="今日 Token" :value="stats.core_stats?.today_tokens || 0">
<template #suffix>
<span style="font-size: 14px; color: #909399; margin-left: 8px;">
剩余{{ stats.core_stats?.remaining_tokens || 0 }}
</span>
</template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="当月 Token" :value="stats.core_stats?.month_tokens || 0" />
</el-col>
</el-row>
</el-card>
<!-- Token 消耗图表 -->
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="12">
<el-card>
<template #header>
<span> 30 Token 消耗</span>
</template>
<div ref="dailyChartRef" style="height: 300px;"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<span> 12 Token 消耗</span>
</template>
<div ref="monthlyChartRef" style="height: 300px;"></div>
</el-card>
</el-col>
</el-row>
<!-- 最近互动记录 -->
<el-card style="margin-top: 20px;">
<template #header>
<span>最近互动记录</span>
</template>
<el-table :data="recentInteractions" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="virtual_user_id" label="用户 ID" width="100" />
<el-table-column prop="interaction_type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" :type="getTypeTag(row.interaction_type)">
{{ getTypeLabel(row.interaction_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="getStatusTag(row.status)">
{{ getStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="execution_time" label="执行时间">
<template #default="{ row }">
{{ formatTime(row.execution_time) }}
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import * as echarts from 'echarts'
import { dashboardApi } from '@/api'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const stats = ref({})
const dailyChartRef = ref(null)
const monthlyChartRef = ref(null)
let dailyChart = null
let monthlyChart = null
const todayInteractions = computed(() => {
const s = stats.value.core_stats || {}
return (s.today_comments || 0) + (s.today_replies || 0) +
(s.today_likes || 0) + (s.today_favorites || 0) + (s.today_shares || 0)
})
const recentInteractions = computed(() => {
return stats.value.recent_interactions || []
})
// 加载数据
const loadData = async () => {
loading.value = true
try {
const res = await dashboardApi.getStats()
stats.value = res
// 渲染图表
renderDailyChart()
renderMonthlyChart()
} catch (error) {
console.error('Load dashboard data error:', error)
} finally {
loading.value = false
}
}
// 刷新数据
const refreshData = () => {
loadData()
ElMessage.success('数据已刷新')
}
// 渲染每日 Token 消耗图表
const renderDailyChart = () => {
if (!dailyChartRef.value) return
if (dailyChart) {
dailyChart.dispose()
}
dailyChart = echarts.init(dailyChartRef.value)
const dailyData = stats.value.daily_token_usages || []
dailyChart.setOption({
tooltip: {
trigger: 'axis'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: dailyData.map(item => item.date.substring(5)), // 只显示 MM-DD
boundaryGap: false
},
yAxis: {
type: 'value'
},
series: [{
name: 'Token 消耗',
type: 'line',
smooth: true,
data: dailyData.map(item => item.tokens),
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64, 158, 255, 0.5)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0.05)' }
])
},
itemStyle: {
color: '#409EFF'
}
}]
})
}
// 渲染每月 Token 消耗图表
const renderMonthlyChart = () => {
if (!monthlyChartRef.value) return
if (monthlyChart) {
monthlyChart.dispose()
}
monthlyChart = echarts.init(monthlyChartRef.value)
const monthlyData = stats.value.monthly_token_usages || []
monthlyChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: monthlyData.map(item => item.month.substring(5)), // 只显示 MM
axisTick: {
alignWithLabel: true
}
},
yAxis: {
type: 'value'
},
series: [{
name: 'Token 消耗',
type: 'bar',
barWidth: '60%',
data: monthlyData.map(item => item.tokens),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 1, color: '#188df0' }
])
}
}]
})
}
// 工具函数
const getTypeLabel = (type) => {
const map = {
'comment': '评论',
'reply': '回复',
'like': '点赞',
'favorite': '收藏',
'share': '转发'
}
return map[type] || type
}
const getTypeTag = (type) => {
const map = {
'comment': 'primary',
'reply': 'success',
'like': 'warning',
'favorite': 'info',
'share': 'danger'
}
return map[type] || ''
}
const getStatusLabel = (status) => {
const map = {
'pending': '待执行',
'success': '成功',
'failed': '失败',
'retrying': '重试中'
}
return map[status] || status
}
const getStatusTag = (status) => {
const map = {
'pending': 'info',
'success': 'success',
'failed': 'danger',
'retrying': 'warning'
}
return map[status] || ''
}
const formatTime = (time) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
}
onMounted(() => {
loadData()
window.addEventListener('resize', () => {
dailyChart?.resize()
monthlyChart?.resize()
})
})
</script>
<style scoped>
.dashboard {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<div class="interactions">
<el-card>
<template #header>
<span>互动记录</span>
</template>
<el-table :data="interactionList" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="virtual_user_id" label="用户 ID" width="100" />
<el-table-column prop="news_title" label="文章标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="interaction_type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" :type="getTypeTag(row.interaction_type)">
{{ getTypeLabel(row.interaction_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="getStatusTag(row.status)">
{{ getStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="tokens_used" label="Token 消耗" width="100" />
<el-table-column prop="execution_time" label="执行时间" width="180">
<template #default="{ row }">
{{ formatTime(row.execution_time) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
style="margin-top: 20px; justify-content: flex-end;"
/>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { interactionApi } from '@/api'
const loading = ref(false)
const interactionList = ref([])
const pagination = ref({
page: 1,
pageSize: 20,
total: 0
})
const loadData = async () => {
loading.value = true
try {
const res = await interactionApi.getList({
page: pagination.value.page,
page_size: pagination.value.pageSize
})
interactionList.value = res.items || []
pagination.value.total = res.total || 0
} catch (error) {
console.error('Load interactions error:', error)
} finally {
loading.value = false
}
}
const getTypeLabel = (type) => {
const map = { comment: '评论', reply: '回复', like: '点赞', favorite: '收藏', share: '转发' }
return map[type] || type
}
const getTypeTag = (type) => {
const map = { comment: 'primary', reply: 'success', like: 'warning', favorite: 'info', share: 'danger' }
return map[type] || ''
}
const getStatusLabel = (status) => {
const map = { pending: '待执行', success: '成功', failed: '失败', retrying: '重试中' }
return map[status] || status
}
const getStatusTag = (status) => {
const map = { pending: 'info', success: 'success', failed: 'danger', retrying: 'warning' }
return map[status] || ''
}
const formatTime = (time) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.interactions {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<div class="settings">
<el-row :gutter="20">
<!-- 活动调度设置 -->
<el-col :span="12">
<el-card>
<template #header>
<span>活动调度设置</span>
</template>
<el-form :model="scheduleForm" label-width="140px">
<el-form-item label="活动开始时间">
<el-time-picker
v-model="scheduleForm.task_start_hour"
format="HH:mm"
value-format="HH"
placeholder="选择时间"
/>
</el-form-item>
<el-form-item label="活动结束时间">
<el-time-picker
v-model="scheduleForm.task_end_hour"
format="HH:mm"
value-format="HH"
placeholder="选择时间"
/>
</el-form-item>
<el-form-item label="最小间隔 (分钟)">
<el-input-number v-model="scheduleForm.task_interval_min" :min="1" :max="60" />
</el-form-item>
<el-form-item label="最大间隔 (分钟)">
<el-input-number v-model="scheduleForm.task_interval_max" :min="1" :max="120" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSaveSchedule" :loading="saving">保存配置</el-button>
</el-form-item>
</el-form>
<el-divider />
<el-form-item label="任务状态">
<el-tag :type="schedulerStatus.is_running ? 'success' : 'info'">
{{ schedulerStatus.is_running ? '运行中' : '已停止' }}
</el-tag>
</el-form-item>
<el-form-item>
<el-button
v-if="!schedulerStatus.is_running"
type="success"
@click="handleStartScheduler"
>
启动任务
</el-button>
<el-button
v-else
type="warning"
@click="handleStopScheduler"
>
停止任务
</el-button>
</el-form-item>
</el-card>
</el-col>
<!-- 限额设置 -->
<el-col :span="12">
<el-card>
<template #header>
<span>限额设置</span>
</template>
<el-form :model="limitForm" label-width="160px">
<el-form-item label="每日 Token 上限">
<el-input-number v-model="limitForm.max_tokens_per_day" :min="0" :step="1000" />
</el-form-item>
<el-form-item label="单用户日评论上限">
<el-input-number v-model="limitForm.max_comments_per_user_per_day" :min="0" :max="100" />
</el-form-item>
<el-form-item label="单用户日回复上限">
<el-input-number v-model="limitForm.max_replies_per_user_per_day" :min="0" :max="50" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSaveLimits" :loading="saving">保存配置</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
<!-- 互动概率设置 -->
<el-card style="margin-top: 20px;">
<template #header>
<span>互动概率设置</span>
</template>
<el-form :model="probabilityForm" label-width="160px" inline>
<el-form-item label="点赞概率">
<el-slider v-model="probabilityForm.like_probability" :min="0" :max="1" :step="0.1" show-input />
</el-form-item>
<el-form-item label="收藏概率">
<el-slider v-model="probabilityForm.favorite_probability" :min="0" :max="1" :step="0.1" show-input />
</el-form-item>
<el-form-item label="转发概率">
<el-slider v-model="probabilityForm.share_probability" :min="0" :max="1" :step="0.1" show-input />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSaveProbabilities" :loading="saving">保存配置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { systemApi } from '@/api'
import { ElMessage } from 'element-plus'
const saving = ref(false)
const scheduleForm = ref({
task_start_hour: '9',
task_end_hour: '22',
task_interval_min: 10,
task_interval_max: 30
})
const limitForm = ref({
max_tokens_per_day: 10000,
max_comments_per_user_per_day: 20,
max_replies_per_user_per_day: 10
})
const probabilityForm = ref({
like_probability: 0.8,
favorite_probability: 0.5,
share_probability: 0.3
})
const schedulerStatus = ref({
is_running: false,
jobs: []
})
// 加载配置
const loadConfig = async () => {
try {
const [schedule, limits, probabilities, status] = await Promise.all([
systemApi.getSchedule(),
systemApi.getLimits(),
systemApi.getProbabilities(),
systemApi.getSchedulerStatus()
])
scheduleForm.value = schedule
limitForm.value = limits
probabilityForm.value = probabilities
schedulerStatus.value = status
} catch (error) {
console.error('Load config error:', error)
}
}
// 保存调度配置
const handleSaveSchedule = async () => {
saving.value = true
try {
await systemApi.updateSchedule(scheduleForm.value)
ElMessage.success('保存成功')
} catch (error) {
console.error('Save schedule error:', error)
} finally {
saving.value = false
}
}
// 保存限额配置
const handleSaveLimits = async () => {
saving.value = true
try {
await systemApi.updateLimits(limitForm.value)
ElMessage.success('保存成功')
} catch (error) {
console.error('Save limits error:', error)
} finally {
saving.value = false
}
}
// 保存概率配置
const handleSaveProbabilities = async () => {
saving.value = true
try {
// TODO: 实现概率配置 API
ElMessage.success('保存成功')
} catch (error) {
console.error('Save probabilities error:', error)
} finally {
saving.value = false
}
}
// 启动调度器
const handleStartScheduler = async () => {
try {
await systemApi.startScheduler()
ElMessage.success('任务已启动')
loadConfig()
} catch (error) {
console.error('Start scheduler error:', error)
}
}
// 停止调度器
const handleStopScheduler = async () => {
try {
await systemApi.stopScheduler()
ElMessage.success('任务已停止')
loadConfig()
} catch (error) {
console.error('Stop scheduler error:', error)
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped>
.settings {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,373 @@
<template>
<div class="virtual-users">
<el-card>
<!-- 操作栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名或昵称"
style="width: 200px; margin-right: 10px;"
clearable
@clear="loadData"
>
<template #append>
<el-button @click="handleSearch">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 120px;" @change="loadData">
<el-option label="启用" value="active" />
<el-option label="禁用" value="disabled" />
</el-select>
</div>
<div class="toolbar-right">
<el-button type="primary" @click="showGenerateDialog = true">
<el-icon><Plus /></el-icon>
批量生成
</el-button>
<el-button type="success" @click="triggerImport">
<el-icon><Upload /></el-icon>
Excel 导入
</el-button>
<input
ref="importFileInput"
type="file"
accept=".xlsx,.xls"
style="display: none"
@change="handleImport"
/>
</div>
</div>
<!-- 用户列表 -->
<el-table
:data="userList"
v-loading="loading"
style="width: 100%; margin-top: 20px;"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="avatar_url" label="头像" width="80">
<template #default="{ row }">
<el-avatar :src="row.avatar_url" :size="40" />
</template>
</el-table-column>
<el-table-column prop="nickname" label="昵称" width="150" />
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="writing_style" label="写作风格" width="120" />
<el-table-column prop="activity_level" label="活跃度" width="100">
<template #default="{ row }">
<el-tag size="small" :type="getActivityLevelTag(row.activity_level)">
{{ getActivityLevelLabel(row.activity_level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">
{{ row.status === 'active' ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_interactions" label="互动次数" width="100" />
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="200">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadData"
@size-change="loadData"
style="margin-top: 20px; justify-content: flex-end;"
/>
</el-card>
<!-- 批量生成对话框 -->
<el-dialog
v-model="showGenerateDialog"
title="批量生成虚拟用户"
width="500px"
>
<el-form :model="generateForm" label-width="120px">
<el-form-item label="生成数量">
<el-input-number v-model="generateForm.count" :min="1" :max="100" />
</el-form-item>
<el-form-item label="写作风格">
<el-select v-model="generateForm.writing_styles" multiple placeholder="不选则随机">
<el-option label="幽默风趣" value="幽默风趣" />
<el-option label="严肃理性" value="严肃理性" />
<el-option label="文艺清新" value="文艺清新" />
<el-option label="吐槽犀利" value="吐槽犀利" />
<el-option label="感性温暖" value="感性温暖" />
</el-select>
</el-form-item>
<el-form-item label="活跃度">
<el-checkbox-group v-model="generateForm.activity_levels">
<el-checkbox label="low"></el-checkbox>
<el-checkbox label="medium"></el-checkbox>
<el-checkbox label="high"></el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="AI 人格描述">
<el-switch v-model="generateForm.generate_persona" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showGenerateDialog = false">取消</el-button>
<el-button type="primary" @click="handleGenerate" :loading="generating">生成</el-button>
</template>
</el-dialog>
<!-- 编辑对话框 -->
<el-dialog
v-model="showEditDialog"
title="编辑虚拟用户"
width="600px"
>
<el-form :model="editForm" label-width="120px">
<el-form-item label="昵称">
<el-input v-model="editForm.nickname" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="editForm.username" disabled />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="editForm.password" type="password" show-password placeholder="不修改则留空" />
</el-form-item>
<el-form-item label="写作风格">
<el-select v-model="editForm.writing_style">
<el-option label="幽默风趣" value="幽默风趣" />
<el-option label="严肃理性" value="严肃理性" />
<el-option label="文艺清新" value="文艺清新" />
<el-option label="吐槽犀利" value="吐槽犀利" />
<el-option label="感性温暖" value="感性温暖" />
</el-select>
</el-form-item>
<el-form-item label="活跃度">
<el-radio-group v-model="editForm.activity_level">
<el-radio label="low"></el-radio>
<el-radio label="medium"></el-radio>
<el-radio label="high"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="editForm.status">
<el-radio label="active">启用</el-radio>
<el-radio label="disabled">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">取消</el-button>
<el-button type="primary" @click="handleSubmitEdit" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { virtualUserApi } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const generating = ref(false)
const saving = ref(false)
const searchKeyword = ref('')
const statusFilter = ref('')
const userList = ref([])
const showGenerateDialog = ref(false)
const showEditDialog = ref(false)
const importFileInput = ref(null)
const pagination = ref({
page: 1,
pageSize: 20,
total: 0
})
const generateForm = ref({
count: 10,
writing_styles: [],
activity_levels: ['low', 'medium', 'high'],
generate_persona: true
})
const editForm = ref({})
const currentUserId = ref(null)
// 加载数据
const loadData = async () => {
loading.value = true
try {
const params = {
page: pagination.value.page,
page_size: pagination.value.pageSize
}
if (searchKeyword.value) {
params.search = searchKeyword.value
}
if (statusFilter.value) {
params.status = statusFilter.value
}
const res = await virtualUserApi.getList(params)
userList.value = res.items || []
pagination.value.total = res.total || 0
} catch (error) {
console.error('Load user list error:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.value.page = 1
loadData()
}
// 批量生成
const handleGenerate = async () => {
generating.value = true
try {
await virtualUserApi.generate(generateForm.value)
ElMessage.success(`成功生成 ${generateForm.value.count} 个虚拟用户`)
showGenerateDialog.value = false
loadData()
} catch (error) {
console.error('Generate users error:', error)
} finally {
generating.value = false
}
}
// Excel 导入
const triggerImport = () => {
importFileInput.value?.click()
}
const handleImport = async (event) => {
const file = event.target.files[0]
if (!file) return
try {
const res = await virtualUserApi.import(file, true)
ElMessage.success(`导入完成:成功${res.success_count}个,失败${res.failed_count}`)
loadData()
} catch (error) {
console.error('Import users error:', error)
} finally {
event.target.value = ''
}
}
// 编辑
const handleEdit = (row) => {
currentUserId.value = row.id
editForm.value = { ...row }
showEditDialog.value = true
}
// 提交编辑
const handleSubmitEdit = async () => {
saving.value = true
try {
const data = { ...editForm.value }
if (!data.password) {
delete data.password
}
await virtualUserApi.update(currentUserId.value, data)
ElMessage.success('保存成功')
showEditDialog.value = false
loadData()
} catch (error) {
console.error('Update user error:', error)
} finally {
saving.value = false
}
}
// 删除
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除虚拟用户 "${row.nickname}" 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
await virtualUserApi.delete(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error) {
console.error('Delete user error:', error)
}
})
}
// 工具函数
const getActivityLevelLabel = (level) => {
const map = { low: '低', medium: '中', high: '高' }
return map[level] || level
}
const getActivityLevelTag = (level) => {
const map = { low: 'info', medium: '', high: 'success' }
return map[level] || ''
}
const formatTime = (time) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.virtual-users {
padding: 20px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
}
.toolbar-left, .toolbar-right {
display: flex;
align-items: center;
}
</style>

21
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
})

89
start.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/bin/bash
# 会会虚拟用户 AI 互动系统 - 快速启动脚本
set -e
echo "======================================"
echo " 会会虚拟用户 AI 互动系统"
echo " 快速启动脚本"
echo "======================================"
echo ""
# 检查 Docker 是否安装
if ! command -v docker &> /dev/null; then
echo "错误:未检测到 Docker请先安装 Docker"
exit 1
fi
# 检查 Docker Compose 是否安装
if ! command -v docker-compose &> /dev/null; then
echo "错误:未检测到 Docker Compose请先安装 Docker Compose"
exit 1
fi
echo "✓ Docker 版本:$(docker --version)"
echo "✓ Docker Compose 版本:$(docker-compose --version)"
echo ""
# 创建必要的目录
echo "正在创建必要的目录..."
mkdir -p data/mysql data/logs data/uploads
chmod -R 755 data/
# 检查 .env 文件
if [ ! -f backend/.env ]; then
echo "正在创建 .env 配置文件..."
cp backend/.env.example backend/.env
echo "⚠️ 请编辑 backend/.env 文件,配置必要参数(特别是 AI 模型 API Key"
echo ""
fi
# 询问是否重新构建
read -p "是否重新构建 Docker 镜像?(y/n): " rebuild
echo ""
echo "正在启动服务..."
if [ "$rebuild" = "y" ] || [ "$rebuild" = "Y" ]; then
docker-compose build
fi
docker-compose up -d
echo ""
echo "======================================"
echo " 服务启动完成!"
echo "======================================"
echo ""
echo "服务访问地址:"
echo " - 前端界面http://localhost"
echo " - 后端 API: http://localhost:8000"
echo " - API 文档http://localhost:8000/docs"
echo ""
echo "查看日志:"
echo " docker-compose logs -f"
echo ""
echo "停止服务:"
echo " docker-compose down"
echo ""
# 等待服务启动
echo "等待服务启动..."
sleep 10
# 健康检查
if curl -s http://localhost:8000/health > /dev/null; then
echo "✓ 后端服务运行正常"
else
echo "⚠️ 后端服务可能还未完全启动,请稍后检查"
fi
echo ""
echo "首次使用请执行以下操作:"
echo "1. 访问 http://localhost:8000/docs 查看 API 文档"
echo "2. 在 AI 模型配置页面添加您的 AI 模型 API Key"
echo "3. 在虚拟用户管理页面生成或导入虚拟用户"
echo "4. 在系统设置页面配置活动时间和限额"
echo "5. 启动定时任务开始自动互动"
echo ""