diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..fa7cd3e --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1e0aaf1 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..ff6fe55 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,3 @@ +""" +API 路由模块初始化 +""" diff --git a/backend/app/api/ai_model.py b/backend/app/api/ai_model.py new file mode 100644 index 0000000..5f62152 --- /dev/null +++ b/backend/app/api/ai_model.py @@ -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 diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py new file mode 100644 index 0000000..7d6a5ee --- /dev/null +++ b/backend/app/api/dashboard.py @@ -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] diff --git a/backend/app/api/interaction.py b/backend/app/api/interaction.py new file mode 100644 index 0000000..4c261fa --- /dev/null +++ b/backend/app/api/interaction.py @@ -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") diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..7e5205e --- /dev/null +++ b/backend/app/api/router.py @@ -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=["控制台"]) diff --git a/backend/app/api/system_config.py b/backend/app/api/system_config.py new file mode 100644 index 0000000..13b1b7f --- /dev/null +++ b/backend/app/api/system_config.py @@ -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()] + } diff --git a/backend/app/api/virtual_user.py b/backend/app/api/virtual_user.py new file mode 100644 index 0000000..70f078b --- /dev/null +++ b/backend/app/api/virtual_user.py @@ -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 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..5b08ae6 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,6 @@ +""" +核心模块初始化 +""" +from .config import settings + +__all__ = ["settings"] diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..3298b10 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..979805e --- /dev/null +++ b/backend/app/main.py @@ -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 + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..94b6cc0 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/ai_model.py b/backend/app/models/ai_model.py new file mode 100644 index 0000000..b95b986 --- /dev/null +++ b/backend/app/models/ai_model.py @@ -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"" diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..4518a36 --- /dev/null +++ b/backend/app/models/base.py @@ -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") diff --git a/backend/app/models/interaction.py b/backend/app/models/interaction.py new file mode 100644 index 0000000..229bd19 --- /dev/null +++ b/backend/app/models/interaction.py @@ -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"" diff --git a/backend/app/models/news_cache.py b/backend/app/models/news_cache.py new file mode 100644 index 0000000..4d9cb06 --- /dev/null +++ b/backend/app/models/news_cache.py @@ -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"" diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py new file mode 100644 index 0000000..f23ae55 --- /dev/null +++ b/backend/app/models/system_config.py @@ -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"" diff --git a/backend/app/models/token_usage.py b/backend/app/models/token_usage.py new file mode 100644 index 0000000..4d36640 --- /dev/null +++ b/backend/app/models/token_usage.py @@ -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"" diff --git a/backend/app/models/virtual_user.py b/backend/app/models/virtual_user.py new file mode 100644 index 0000000..bb38623 --- /dev/null +++ b/backend/app/models/virtual_user.py @@ -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"" + + +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"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..22a4bae --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/ai_model.py b/backend/app/schemas/ai_model.py new file mode 100644 index 0000000..8516051 --- /dev/null +++ b/backend/app/schemas/ai_model.py @@ -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] diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py new file mode 100644 index 0000000..a770e42 --- /dev/null +++ b/backend/app/schemas/dashboard.py @@ -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) diff --git a/backend/app/schemas/interaction.py b/backend/app/schemas/interaction.py new file mode 100644 index 0000000..d5a1e6d --- /dev/null +++ b/backend/app/schemas/interaction.py @@ -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="是否强制执行(忽略限额)") diff --git a/backend/app/schemas/system_config.py b/backend/app/schemas/system_config.py new file mode 100644 index 0000000..80b9d6c --- /dev/null +++ b/backend/app/schemas/system_config.py @@ -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) diff --git a/backend/app/schemas/token_usage.py b/backend/app/schemas/token_usage.py new file mode 100644 index 0000000..4f8fb54 --- /dev/null +++ b/backend/app/schemas/token_usage.py @@ -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] diff --git a/backend/app/schemas/virtual_user.py b/backend/app/schemas/virtual_user.py new file mode 100644 index 0000000..9d1a426 --- /dev/null +++ b/backend/app/schemas/virtual_user.py @@ -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 人格描述") diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..23b7962 --- /dev/null +++ b/backend/app/services/__init__.py @@ -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", +] diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py new file mode 100644 index 0000000..6ef204f --- /dev/null +++ b/backend/app/services/ai_service.py @@ -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() diff --git a/backend/app/services/huihui_api.py b/backend/app/services/huihui_api.py new file mode 100644 index 0000000..deb9acf --- /dev/null +++ b/backend/app/services/huihui_api.py @@ -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() diff --git a/backend/app/services/interaction_service.py b/backend/app/services/interaction_service.py new file mode 100644 index 0000000..f167eb6 --- /dev/null +++ b/backend/app/services/interaction_service.py @@ -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) diff --git a/backend/app/services/scheduler_service.py b/backend/app/services/scheduler_service.py new file mode 100644 index 0000000..877058a --- /dev/null +++ b/backend/app/services/scheduler_service.py @@ -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() diff --git a/backend/app/services/token_service.py b/backend/app/services/token_service.py new file mode 100644 index 0000000..7838a71 --- /dev/null +++ b/backend/app/services/token_service.py @@ -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) diff --git a/backend/app/services/virtual_user_service.py b/backend/app/services/virtual_user_service.py new file mode 100644 index 0000000..0a93afc --- /dev/null +++ b/backend/app/services/virtual_user_service.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9d552cf --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/data/logs/.gitkeep b/data/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/uploads/.gitkeep b/data/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/mysql/init.sql b/docker/mysql/init.sql new file mode 100644 index 0000000..9765df5 --- /dev/null +++ b/docker/mysql/init.sql @@ -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); diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..09a039c --- /dev/null +++ b/docker/nginx/nginx.conf @@ -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; +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a14a6f6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 会会虚拟用户 AI 互动系统 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1b78111 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..7e86184 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..18a1a35 --- /dev/null +++ b/frontend/src/api/index.js @@ -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') +} diff --git a/frontend/src/api/request.js b/frontend/src/api/request.js new file mode 100644 index 0000000..49ad4c2 --- /dev/null +++ b/frontend/src/api/request.js @@ -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 diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..fc84b78 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..7665315 --- /dev/null +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/views/AIModels.vue b/frontend/src/views/AIModels.vue new file mode 100644 index 0000000..2a95d78 --- /dev/null +++ b/frontend/src/views/AIModels.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..770a8e7 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/frontend/src/views/Interactions.vue b/frontend/src/views/Interactions.vue new file mode 100644 index 0000000..5bf5f79 --- /dev/null +++ b/frontend/src/views/Interactions.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..056f90a --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/frontend/src/views/VirtualUsers.vue b/frontend/src/views/VirtualUsers.vue new file mode 100644 index 0000000..2283456 --- /dev/null +++ b/frontend/src/views/VirtualUsers.vue @@ -0,0 +1,373 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..3bd21ee --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } + } + } +}) diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..676b5a5 --- /dev/null +++ b/start.sh @@ -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 ""