1.0.0初始化源代码
This commit is contained in:
55
backend/.env.example
Normal file
55
backend/.env.example
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 应用基础配置
|
||||||
|
APP_NAME=会会虚拟用户 AI 互动系统
|
||||||
|
APP_VERSION=1.0.0
|
||||||
|
DEBUG=False
|
||||||
|
API_PREFIX=/api/v1
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
DATABASE_HOST=mysql
|
||||||
|
DATABASE_PORT=3306
|
||||||
|
DATABASE_USER=root
|
||||||
|
DATABASE_PASSWORD=root123456
|
||||||
|
DATABASE_NAME=huihui_ai_bot
|
||||||
|
# 或者使用完整的 DATABASE_URL
|
||||||
|
# DATABASE_URL=mysql+pymysql://root:root123456@mysql:3306/huihui_ai_bot?charset=utf8mb4
|
||||||
|
|
||||||
|
# JWT 配置(生产环境请修改)
|
||||||
|
JWT_SECRET_KEY=your-secret-key-change-in-production-abc123xyz
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_EXPIRE_MINUTES=10080
|
||||||
|
|
||||||
|
# 会会接口配置
|
||||||
|
HUIHUI_API_BASE=http://192.168.1.200:63120
|
||||||
|
HUIHUI_DOC_URL=http://192.168.1.200:63120/doc.html
|
||||||
|
|
||||||
|
# AI 模型配置
|
||||||
|
DEFAULT_AI_MODEL=openai
|
||||||
|
|
||||||
|
# OpenAI 配置
|
||||||
|
OPENAI_API_KEY=sk-your-openai-api-key
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL=gpt-3.5-turbo
|
||||||
|
|
||||||
|
# 智谱 AI 配置
|
||||||
|
ZHIPU_API_KEY=your-zhipu-api-key
|
||||||
|
ZHIPU_MODEL=glm-4
|
||||||
|
|
||||||
|
# 系统限制配置
|
||||||
|
MAX_TOKENS_PER_DAY=10000
|
||||||
|
MAX_COMMENTS_PER_USER_PER_DAY=20
|
||||||
|
MAX_REPLIES_PER_USER_PER_DAY=10
|
||||||
|
|
||||||
|
# 定时任务配置
|
||||||
|
TASK_START_HOUR=9
|
||||||
|
TASK_END_HOUR=22
|
||||||
|
TASK_INTERVAL_MIN=10
|
||||||
|
TASK_INTERVAL_MAX=30
|
||||||
|
|
||||||
|
# 互动概率配置
|
||||||
|
LIKE_PROBABILITY=0.8
|
||||||
|
FAVORITE_PROBABILITY=0.5
|
||||||
|
SHARE_PROBABILITY=0.3
|
||||||
|
|
||||||
|
# 文件存储配置
|
||||||
|
UPLOAD_DIR=/app/data/uploads
|
||||||
|
LOG_DIR=/app/data/logs
|
||||||
39
backend/Dockerfile
Normal file
39
backend/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
pkg-config \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 创建数据目录
|
||||||
|
RUN mkdir -p /app/data/uploads /app/data/logs
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import httpx; httpx.get('http://localhost:8000/health')"
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
3
backend/app/api/__init__.py
Normal file
3
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
API 路由模块初始化
|
||||||
|
"""
|
||||||
136
backend/app/api/ai_model.py
Normal file
136
backend/app/api/ai_model.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
AI 模型配置 API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.models.base import get_db
|
||||||
|
from app.models.ai_model import AIModelConfig
|
||||||
|
from app.schemas.ai_model import (
|
||||||
|
AIModelConfigCreate,
|
||||||
|
AIModelConfigUpdate,
|
||||||
|
AIModelConfigResponse,
|
||||||
|
AIModelTestRequest,
|
||||||
|
AIModelTestResponse
|
||||||
|
)
|
||||||
|
from app.services.ai_service import ai_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[AIModelConfigResponse])
|
||||||
|
def get_ai_models(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取所有 AI 模型配置"""
|
||||||
|
models = db.query(AIModelConfig).all()
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{model_id}", response_model=AIModelConfigResponse)
|
||||||
|
def get_ai_model(
|
||||||
|
model_id: int,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取 AI 模型详情"""
|
||||||
|
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
|
||||||
|
if not model:
|
||||||
|
raise HTTPException(status_code=404, detail="Model not found")
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=AIModelConfigResponse)
|
||||||
|
def create_ai_model(
|
||||||
|
model_data: AIModelConfigCreate,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""创建 AI 模型配置"""
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = db.query(AIModelConfig).filter(
|
||||||
|
AIModelConfig.model_name == model_data.model_name
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Model already exists")
|
||||||
|
|
||||||
|
model = AIModelConfig(**model_data.model_dump())
|
||||||
|
|
||||||
|
# 如果是第一个模型,设为默认
|
||||||
|
if not db.query(AIModelConfig).filter(AIModelConfig.is_default == True).first():
|
||||||
|
model.is_default = True
|
||||||
|
|
||||||
|
db.add(model)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(model)
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{model_id}", response_model=AIModelConfigResponse)
|
||||||
|
def update_ai_model(
|
||||||
|
model_id: int,
|
||||||
|
model_data: AIModelConfigUpdate,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""更新 AI 模型配置"""
|
||||||
|
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
|
||||||
|
if not model:
|
||||||
|
raise HTTPException(status_code=404, detail="Model not found")
|
||||||
|
|
||||||
|
update_data = model_data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# 如果设置默认模型,先取消其他模型的默认状态
|
||||||
|
if update_data.get("is_default"):
|
||||||
|
db.query(AIModelConfig).update({"is_default": False})
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(model, key, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(model)
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{model_id}")
|
||||||
|
def delete_ai_model(
|
||||||
|
model_id: int,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""删除 AI 模型配置"""
|
||||||
|
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
|
||||||
|
if not model:
|
||||||
|
raise HTTPException(status_code=404, detail="Model not found")
|
||||||
|
|
||||||
|
db.delete(model)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Model deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test", response_model=AIModelTestResponse)
|
||||||
|
async def test_ai_model(
|
||||||
|
request: AIModelTestRequest,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""测试 AI 模型"""
|
||||||
|
model = db.query(AIModelConfig).filter(AIModelConfig.id == request.model_id).first()
|
||||||
|
if not model:
|
||||||
|
raise HTTPException(status_code=404, detail="Model not found")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"provider": model.provider,
|
||||||
|
"model_name": model.model_name,
|
||||||
|
"api_key": model.api_key,
|
||||||
|
"api_url": model.api_url,
|
||||||
|
"temperature": model.temperature,
|
||||||
|
"max_tokens": model.max_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await ai_service.test_model(
|
||||||
|
model_config=model_config,
|
||||||
|
test_prompt=request.test_prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
150
backend/app/api/dashboard.py
Normal file
150
backend/app/api/dashboard.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
控制台 API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from app.models.base import get_db
|
||||||
|
from app.schemas.dashboard import DashboardStats, CoreStats, DailyUsageItem, MonthlyUsageItem
|
||||||
|
from app.services.token_service import TokenService, get_token_service
|
||||||
|
from app.services.virtual_user_service import VirtualUserService, get_virtual_user_service
|
||||||
|
from app.models.virtual_user import VirtualUser, UserStatus
|
||||||
|
from app.models.interaction import InteractionRecord, InteractionType, InteractionStatus
|
||||||
|
from app.models.token_usage import TokenUsage
|
||||||
|
from sqlalchemy import func, and_
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DashboardStats)
|
||||||
|
def get_dashboard_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
token_service: TokenService = Depends(get_token_service),
|
||||||
|
user_service: VirtualUserService = Depends(get_virtual_user_service)
|
||||||
|
):
|
||||||
|
"""获取控制台统计数据"""
|
||||||
|
|
||||||
|
# 核心指标统计
|
||||||
|
today = date.today()
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
|
||||||
|
# 用户统计
|
||||||
|
total_users = db.query(VirtualUser).count()
|
||||||
|
active_users = db.query(VirtualUser).filter(VirtualUser.status == UserStatus.ACTIVE).count()
|
||||||
|
disabled_users = total_users - active_users
|
||||||
|
|
||||||
|
# 今日互动统计
|
||||||
|
today_interactions = db.query(InteractionRecord).filter(
|
||||||
|
and_(
|
||||||
|
func.date(InteractionRecord.execution_time) == today,
|
||||||
|
InteractionRecord.status == InteractionStatus.SUCCESS
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
today_comments = sum(1 for i in today_interactions if i.interaction_type == InteractionType.COMMENT)
|
||||||
|
today_replies = sum(1 for i in today_interactions if i.interaction_type == InteractionType.REPLY)
|
||||||
|
today_likes = sum(1 for i in today_interactions if i.interaction_type == InteractionType.LIKE)
|
||||||
|
today_favorites = sum(1 for i in today_interactions if i.interaction_type == InteractionType.FAVORITE)
|
||||||
|
today_shares = sum(1 for i in today_interactions if i.interaction_type == InteractionType.SHARE)
|
||||||
|
|
||||||
|
# 昨日互动统计
|
||||||
|
yesterday_interactions = db.query(InteractionRecord).filter(
|
||||||
|
and_(
|
||||||
|
func.date(InteractionRecord.execution_time) == yesterday,
|
||||||
|
InteractionRecord.status == InteractionStatus.SUCCESS
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
yesterday_comments = sum(1 for i in yesterday_interactions if i.interaction_type == InteractionType.COMMENT)
|
||||||
|
yesterday_replies = sum(1 for i in yesterday_interactions if i.interaction_type == InteractionType.REPLY)
|
||||||
|
|
||||||
|
# Token 统计
|
||||||
|
today_tokens = token_service.get_today_usage()
|
||||||
|
month_tokens = token_service.get_month_usage()
|
||||||
|
remaining_tokens = token_service.get_remaining_tokens()
|
||||||
|
|
||||||
|
core_stats = CoreStats(
|
||||||
|
total_users=total_users,
|
||||||
|
active_users=active_users,
|
||||||
|
disabled_users=disabled_users,
|
||||||
|
today_comments=today_comments,
|
||||||
|
today_replies=today_replies,
|
||||||
|
today_likes=today_likes,
|
||||||
|
today_favorites=today_favorites,
|
||||||
|
today_shares=today_shares,
|
||||||
|
yesterday_comments=yesterday_comments,
|
||||||
|
yesterday_replies=yesterday_replies,
|
||||||
|
month_tokens=month_tokens,
|
||||||
|
today_tokens=today_tokens,
|
||||||
|
remaining_tokens=remaining_tokens
|
||||||
|
)
|
||||||
|
|
||||||
|
# 每日 Token 使用(近 30 天)
|
||||||
|
daily_usages = token_service.get_daily_usages(days=30)
|
||||||
|
daily_items = [DailyUsageItem(date=u["date"], tokens=u["tokens"], comments=0, replies=0) for u in daily_usages]
|
||||||
|
|
||||||
|
# 每月 Token 使用(近 12 个月)
|
||||||
|
monthly_usages = token_service.get_monthly_usages(months=12)
|
||||||
|
monthly_items = [MonthlyUsageItem(month=u["month"], tokens=u["tokens"]) for u in monthly_usages]
|
||||||
|
|
||||||
|
# 最近互动记录
|
||||||
|
recent_interactions = db.query(InteractionRecord).order_by(
|
||||||
|
InteractionRecord.execution_time.desc()
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
return DashboardStats(
|
||||||
|
core_stats=core_stats,
|
||||||
|
daily_token_usages=daily_items,
|
||||||
|
monthly_token_usages=monthly_items,
|
||||||
|
recent_interactions=[
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"virtual_user_id": r.virtual_user_id,
|
||||||
|
"interaction_type": r.interaction_type.value,
|
||||||
|
"status": r.status.value,
|
||||||
|
"execution_time": r.execution_time
|
||||||
|
}
|
||||||
|
for r in recent_interactions
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/token/stats")
|
||||||
|
def get_token_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
token_service: TokenService = Depends(get_token_service)
|
||||||
|
):
|
||||||
|
"""获取 Token 统计"""
|
||||||
|
today_used = token_service.get_today_usage()
|
||||||
|
today_limit = 10000 # TODO: 从系统配置读取
|
||||||
|
|
||||||
|
return {
|
||||||
|
"today_used": today_used,
|
||||||
|
"today_limit": today_limit,
|
||||||
|
"today_remaining": max(0, today_limit - today_used),
|
||||||
|
"usage_percentage": round((today_used / today_limit) * 100, 2) if today_limit > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/token/daily", response_model=List[DailyUsageItem])
|
||||||
|
def get_daily_token_usage(
|
||||||
|
days: int = Query(30, ge=1, le=90, description="天数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
token_service: TokenService = Depends(get_token_service)
|
||||||
|
):
|
||||||
|
"""获取每日 Token 使用"""
|
||||||
|
usages = token_service.get_daily_usages(days=days)
|
||||||
|
return [DailyUsageItem(date=u["date"], tokens=u["tokens"], comments=0, replies=0) for u in usages]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/token/monthly", response_model=List[MonthlyUsageItem])
|
||||||
|
def get_monthly_token_usage(
|
||||||
|
months: int = Query(12, ge=1, le=24, description="月数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
token_service: TokenService = Depends(get_token_service)
|
||||||
|
):
|
||||||
|
"""获取每月 Token 使用"""
|
||||||
|
usages = token_service.get_monthly_usages(months=months)
|
||||||
|
return [MonthlyUsageItem(month=u["month"], tokens=u["tokens"]) for u in usages]
|
||||||
72
backend/app/api/interaction.py
Normal file
72
backend/app/api/interaction.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
互动管理 API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.models.base import get_db
|
||||||
|
from app.models.interaction import InteractionType
|
||||||
|
from app.schemas.interaction import (
|
||||||
|
InteractionRecordResponse,
|
||||||
|
InteractionRecordListResponse,
|
||||||
|
InteractionExecuteRequest
|
||||||
|
)
|
||||||
|
from app.services.interaction_service import InteractionService, get_interaction_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=InteractionRecordListResponse)
|
||||||
|
def get_interaction_records(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
virtual_user_id: Optional[int] = Query(None, description="虚拟用户 ID"),
|
||||||
|
interaction_type: Optional[InteractionType] = Query(None, description="互动类型"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: InteractionService = Depends(get_interaction_service)
|
||||||
|
):
|
||||||
|
"""获取互动记录列表"""
|
||||||
|
# TODO: 实现筛选和分页查询
|
||||||
|
return {"total": 0, "items": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{record_id}", response_model=InteractionRecordResponse)
|
||||||
|
def get_interaction_record(
|
||||||
|
record_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: InteractionService = Depends(get_interaction_service)
|
||||||
|
):
|
||||||
|
"""获取互动记录详情"""
|
||||||
|
# TODO: 实现详情查询
|
||||||
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/execute", response_model=InteractionRecordResponse)
|
||||||
|
async def execute_interaction(
|
||||||
|
request: InteractionExecuteRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: InteractionService = Depends(get_interaction_service)
|
||||||
|
):
|
||||||
|
"""执行互动"""
|
||||||
|
record = await service.execute_interaction(
|
||||||
|
virtual_user_id=request.virtual_user_id,
|
||||||
|
interaction_type=request.interaction_type,
|
||||||
|
news_id=request.news_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=400, detail="Failed to execute interaction")
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/retry/{record_id}")
|
||||||
|
async def retry_interaction(
|
||||||
|
record_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: InteractionService = Depends(get_interaction_service)
|
||||||
|
):
|
||||||
|
"""重试失败的互动"""
|
||||||
|
# TODO: 实现重试逻辑
|
||||||
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
19
backend/app/api/router.py
Normal file
19
backend/app/api/router.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
API 路由
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .virtual_user import router as virtual_user_router
|
||||||
|
from .interaction import router as interaction_router
|
||||||
|
from .ai_model import router as ai_model_router
|
||||||
|
from .system_config import router as system_config_router
|
||||||
|
from .dashboard import router as dashboard_router
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
# 注册各模块路由
|
||||||
|
api_router.include_router(virtual_user_router, prefix="/virtual-users", tags=["虚拟用户管理"])
|
||||||
|
api_router.include_router(interaction_router, prefix="/interactions", tags=["互动管理"])
|
||||||
|
api_router.include_router(ai_model_router, prefix="/ai-models", tags=["AI 模型配置"])
|
||||||
|
api_router.include_router(system_config_router, prefix="/system", tags=["系统设置"])
|
||||||
|
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["控制台"])
|
||||||
126
backend/app/api/system_config.py
Normal file
126
backend/app/api/system_config.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
系统配置 API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.models.base import get_db
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
from app.schemas.system_config import (
|
||||||
|
SystemConfigResponse,
|
||||||
|
SystemConfigUpdate,
|
||||||
|
ScheduleConfig,
|
||||||
|
LimitConfig,
|
||||||
|
ProbabilityConfig
|
||||||
|
)
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[SystemConfigResponse])
|
||||||
|
def get_system_configs(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取所有系统配置"""
|
||||||
|
configs = db.query(SystemConfig).all()
|
||||||
|
return configs
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/schedule", response_model=ScheduleConfig)
|
||||||
|
def get_schedule_config(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取调度配置"""
|
||||||
|
from app.services.scheduler_service import scheduler_service
|
||||||
|
|
||||||
|
return ScheduleConfig(
|
||||||
|
task_start_hour=settings.TASK_START_HOUR,
|
||||||
|
task_end_hour=settings.TASK_END_HOUR,
|
||||||
|
task_interval_min=settings.TASK_INTERVAL_MIN,
|
||||||
|
task_interval_max=settings.TASK_INTERVAL_MAX,
|
||||||
|
is_task_running=scheduler_service.is_running
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/limits", response_model=LimitConfig)
|
||||||
|
def get_limit_config(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取限额配置"""
|
||||||
|
return LimitConfig(
|
||||||
|
max_tokens_per_day=settings.MAX_TOKENS_PER_DAY,
|
||||||
|
max_comments_per_user_per_day=settings.MAX_COMMENTS_PER_USER_PER_DAY,
|
||||||
|
max_replies_per_user_per_day=settings.MAX_REPLIES_PER_USER_PER_DAY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/probabilities", response_model=ProbabilityConfig)
|
||||||
|
def get_probability_config(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取概率配置"""
|
||||||
|
return ProbabilityConfig(
|
||||||
|
like_probability=settings.LIKE_PROBABILITY,
|
||||||
|
favorite_probability=settings.FAVORITE_PROBABILITY,
|
||||||
|
share_probability=settings.SHARE_PROBABILITY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/schedule")
|
||||||
|
def update_schedule_config(
|
||||||
|
config: ScheduleConfig,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""更新调度配置"""
|
||||||
|
# TODO: 更新系统配置表并重新加载
|
||||||
|
return {"message": "Schedule config updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/limits")
|
||||||
|
def update_limit_config(
|
||||||
|
config: LimitConfig,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""更新限额配置"""
|
||||||
|
# TODO: 更新系统配置表
|
||||||
|
return {"message": "Limit config updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scheduler/start")
|
||||||
|
def start_scheduler(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""启动定时任务"""
|
||||||
|
from app.services.scheduler_service import scheduler_service
|
||||||
|
|
||||||
|
scheduler_service.start()
|
||||||
|
scheduler_service.add_interaction_task()
|
||||||
|
|
||||||
|
return {"message": "Scheduler started", "running": scheduler_service.is_running}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scheduler/stop")
|
||||||
|
def stop_scheduler(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""停止定时任务"""
|
||||||
|
from app.services.scheduler_service import scheduler_service
|
||||||
|
|
||||||
|
scheduler_service.stop()
|
||||||
|
|
||||||
|
return {"message": "Scheduler stopped", "running": scheduler_service.is_running}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scheduler/status")
|
||||||
|
def get_scheduler_status(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取定时任务状态"""
|
||||||
|
from app.services.scheduler_service import scheduler_service
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_running": scheduler_service.is_running,
|
||||||
|
"jobs": [job.id for job in scheduler_service.scheduler.get_jobs()]
|
||||||
|
}
|
||||||
162
backend/app/api/virtual_user.py
Normal file
162
backend/app/api/virtual_user.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
虚拟用户管理 API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
import pandas as pd
|
||||||
|
import io
|
||||||
|
|
||||||
|
from app.models.base import get_db
|
||||||
|
from app.schemas.virtual_user import (
|
||||||
|
VirtualUserCreate,
|
||||||
|
VirtualUserUpdate,
|
||||||
|
VirtualUserResponse,
|
||||||
|
VirtualUserListResponse,
|
||||||
|
VirtualUserGenerateRequest,
|
||||||
|
ActivityLevel,
|
||||||
|
UserStatus
|
||||||
|
)
|
||||||
|
from app.services.virtual_user_service import VirtualUserService, get_virtual_user_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=VirtualUserListResponse)
|
||||||
|
def get_virtual_users(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
status: Optional[UserStatus] = Query(None, description="状态筛选"),
|
||||||
|
search: Optional[str] = Query(None, description="搜索关键词"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: VirtualUserService = Depends(get_virtual_user_service)
|
||||||
|
):
|
||||||
|
"""获取虚拟用户列表"""
|
||||||
|
result = service.get_users(page=page, page_size=page_size, status=status, search=search)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=VirtualUserResponse)
|
||||||
|
def get_virtual_user(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: VirtualUserService = Depends(get_virtual_user_service)
|
||||||
|
):
|
||||||
|
"""获取虚拟用户详情"""
|
||||||
|
user = service.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=VirtualUserResponse)
|
||||||
|
def create_virtual_user(
|
||||||
|
user_data: VirtualUserCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: VirtualUserService = Depends(get_virtual_user_service)
|
||||||
|
):
|
||||||
|
"""创建虚拟用户"""
|
||||||
|
user = service.create_user(
|
||||||
|
username=user_data.username,
|
||||||
|
password=user_data.password,
|
||||||
|
nickname=user_data.nickname,
|
||||||
|
writing_style=user_data.writing_style,
|
||||||
|
activity_level=user_data.activity_level,
|
||||||
|
avatar_url=user_data.avatar_url,
|
||||||
|
persona_description=user_data.persona_description
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=400, detail="Failed to create user (username may exist)")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate", response_model=VirtualUserListResponse)
|
||||||
|
def generate_virtual_users(
|
||||||
|
request: VirtualUserGenerateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: VirtualUserService = Depends(get_virtual_user_service)
|
||||||
|
):
|
||||||
|
"""批量生成虚拟用户"""
|
||||||
|
users = service.generate_users(
|
||||||
|
count=request.count,
|
||||||
|
writing_styles=request.writing_styles,
|
||||||
|
activity_levels=request.activity_levels,
|
||||||
|
generate_persona=request.generate_persona
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"total": len(users), "items": users}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}", response_model=VirtualUserResponse)
|
||||||
|
def update_virtual_user(
|
||||||
|
user_id: int,
|
||||||
|
user_data: VirtualUserUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: VirtualUserService = Depends(get_virtual_user_service)
|
||||||
|
):
|
||||||
|
"""更新虚拟用户"""
|
||||||
|
update_data = user_data.model_dump(exclude_unset=True)
|
||||||
|
user = service.update_user(user_id, **update_data)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}")
|
||||||
|
def delete_virtual_user(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: VirtualUserService = Depends(get_virtual_user_service)
|
||||||
|
):
|
||||||
|
"""删除虚拟用户"""
|
||||||
|
success = service.delete_user(user_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return {"message": "User deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=dict)
|
||||||
|
def import_virtual_users(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
generate_persona: bool = Query(True, description="是否生成 AI 人格描述"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: VirtualUserService = Depends(get_virtual_user_service)
|
||||||
|
):
|
||||||
|
"""从 Excel 导入虚拟用户"""
|
||||||
|
try:
|
||||||
|
# 读取 Excel 文件
|
||||||
|
contents = file.file.read()
|
||||||
|
df = pd.read_excel(io.BytesIO(contents))
|
||||||
|
|
||||||
|
# 转换为字典列表
|
||||||
|
users_data = df.to_dict('records')
|
||||||
|
|
||||||
|
# 导入用户
|
||||||
|
result = service.import_users_from_excel(
|
||||||
|
users_data=users_data,
|
||||||
|
generate_persona=generate_persona
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Import completed",
|
||||||
|
"success_count": result["success_count"],
|
||||||
|
"failed_count": result["failed_count"]
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Import failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/stats")
|
||||||
|
def get_user_stats(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: VirtualUserService = Depends(get_virtual_user_service)
|
||||||
|
):
|
||||||
|
"""获取用户统计信息"""
|
||||||
|
stats = service.get_user_stats(user_id)
|
||||||
|
return stats
|
||||||
6
backend/app/core/__init__.py
Normal file
6
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
核心模块初始化
|
||||||
|
"""
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
__all__ = ["settings"]
|
||||||
76
backend/app/core/config.py
Normal file
76
backend/app/core/config.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
系统配置模块
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import Optional
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""应用配置"""
|
||||||
|
|
||||||
|
# 应用基础配置
|
||||||
|
APP_NAME: str = "会会虚拟用户 AI 互动系统"
|
||||||
|
APP_VERSION: str = "1.0.0"
|
||||||
|
DEBUG: bool = True
|
||||||
|
API_PREFIX: str = "/api/v1"
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
DATABASE_HOST: str = "mysql"
|
||||||
|
DATABASE_PORT: int = 3306
|
||||||
|
DATABASE_USER: str = "root"
|
||||||
|
DATABASE_PASSWORD: str = "root123456"
|
||||||
|
DATABASE_NAME: str = "huihui_ai_bot"
|
||||||
|
DATABASE_URL: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_database_url(self) -> str:
|
||||||
|
if self.DATABASE_URL:
|
||||||
|
return self.DATABASE_URL
|
||||||
|
return f"mysql+pymysql://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}?charset=utf8mb4"
|
||||||
|
|
||||||
|
# JWT 配置
|
||||||
|
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 天
|
||||||
|
|
||||||
|
# 会会接口配置
|
||||||
|
HUIHUI_API_BASE: str = "http://192.168.1.200:63120"
|
||||||
|
HUIHUI_DOC_URL: str = "http://192.168.1.200:63120/doc.html"
|
||||||
|
|
||||||
|
# AI 模型配置(默认)
|
||||||
|
DEFAULT_AI_MODEL: str = "openai"
|
||||||
|
OPENAI_API_KEY: Optional[str] = None
|
||||||
|
OPENAI_BASE_URL: str = "https://api.openai.com/v1"
|
||||||
|
OPENAI_MODEL: str = "gpt-3.5-turbo"
|
||||||
|
|
||||||
|
ZHIPU_API_KEY: Optional[str] = None
|
||||||
|
ZHIPU_MODEL: str = "glm-4"
|
||||||
|
|
||||||
|
# 系统限制配置
|
||||||
|
MAX_TOKENS_PER_DAY: int = 10000 # 每日 Token 上限
|
||||||
|
MAX_COMMENTS_PER_USER_PER_DAY: int = 20 # 单用户每日最大评论数
|
||||||
|
MAX_REPLIES_PER_USER_PER_DAY: int = 10 # 单用户每日最大回复数
|
||||||
|
|
||||||
|
# 定时任务配置
|
||||||
|
TASK_START_HOUR: int = 9 # 活动开始时间
|
||||||
|
TASK_END_HOUR: int = 22 # 活动结束时间
|
||||||
|
TASK_INTERVAL_MIN: int = 10 # 最小间隔(分钟)
|
||||||
|
TASK_INTERVAL_MAX: int = 30 # 最大间隔(分钟)
|
||||||
|
|
||||||
|
# 互动概率配置
|
||||||
|
LIKE_PROBABILITY: float = 0.8 # 点赞概率
|
||||||
|
FAVORITE_PROBABILITY: float = 0.5 # 收藏概率
|
||||||
|
SHARE_PROBABILITY: float = 0.3 # 转发概率
|
||||||
|
|
||||||
|
# 文件存储配置
|
||||||
|
UPLOAD_DIR: str = "/app/data/uploads"
|
||||||
|
LOG_DIR: str = "/app/data/logs"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
# 创建全局配置实例
|
||||||
|
settings = Settings()
|
||||||
95
backend/app/main.py
Normal file
95
backend/app/main.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
FastAPI 应用主文件
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from loguru import logger as loguru_logger
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.base import init_db
|
||||||
|
from app.api.router import api_router
|
||||||
|
from app.services.scheduler_service import scheduler_service
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""应用生命周期管理"""
|
||||||
|
# 启动时执行
|
||||||
|
logger.info("Starting application...")
|
||||||
|
|
||||||
|
# 初始化数据库
|
||||||
|
init_db()
|
||||||
|
logger.info("Database initialized")
|
||||||
|
|
||||||
|
# 启动定时任务
|
||||||
|
scheduler_service.start()
|
||||||
|
scheduler_service.add_interaction_task()
|
||||||
|
scheduler_service.add_login_task(hour=8, minute=0)
|
||||||
|
scheduler_service.reset_daily_counters(hour=0, minute=1)
|
||||||
|
logger.info("Scheduler started")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# 关闭时执行
|
||||||
|
logger.info("Shutting down application...")
|
||||||
|
scheduler_service.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# 创建 FastAPI 应用
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
version=settings.APP_VERSION,
|
||||||
|
description="会会虚拟用户 AI 互动系统后端 API",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# 配置 CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # 生产环境应该配置具体的域名
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注册路由
|
||||||
|
app.include_router(api_router, prefix=settings.API_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""根路径"""
|
||||||
|
return {
|
||||||
|
"name": settings.APP_NAME,
|
||||||
|
"version": settings.APP_VERSION,
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"scheduler_running": scheduler_service.is_running
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=settings.DEBUG
|
||||||
|
)
|
||||||
25
backend/app/models/__init__.py
Normal file
25
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
数据库模型初始化
|
||||||
|
"""
|
||||||
|
from .base import Base, engine, get_db, SessionLocal
|
||||||
|
from .virtual_user import VirtualUser, VirtualUserPersona
|
||||||
|
from .interaction import InteractionRecord, InteractionType
|
||||||
|
from .token_usage import TokenUsage
|
||||||
|
from .system_config import SystemConfig
|
||||||
|
from .ai_model import AIModelConfig
|
||||||
|
from .news_cache import NewsCache
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"engine",
|
||||||
|
"get_db",
|
||||||
|
"SessionLocal",
|
||||||
|
"VirtualUser",
|
||||||
|
"VirtualUserPersona",
|
||||||
|
"InteractionRecord",
|
||||||
|
"InteractionType",
|
||||||
|
"TokenUsage",
|
||||||
|
"SystemConfig",
|
||||||
|
"AIModelConfig",
|
||||||
|
"NewsCache",
|
||||||
|
]
|
||||||
43
backend/app/models/ai_model.py
Normal file
43
backend/app/models/ai_model.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
AI 模型配置模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Float
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelConfig(Base):
|
||||||
|
"""AI 模型配置表"""
|
||||||
|
__tablename__ = "ai_model_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True, comment="配置 ID")
|
||||||
|
|
||||||
|
# 模型基本信息
|
||||||
|
model_name = Column(String(100), unique=True, nullable=False, index=True, comment="模型名称(如 gpt-3.5-turbo)")
|
||||||
|
provider = Column(String(50), nullable=False, comment="提供商(openai/zhipu/baidu/aliyun)")
|
||||||
|
display_name = Column(String(200), comment="显示名称")
|
||||||
|
|
||||||
|
# API 配置
|
||||||
|
api_url = Column(String(500), nullable=False, comment="API 地址")
|
||||||
|
api_key = Column(String(500), nullable=False, comment="API Key(加密存储)")
|
||||||
|
api_version = Column(String(50), comment="API 版本")
|
||||||
|
|
||||||
|
# 模型参数
|
||||||
|
temperature = Column(Float, default=0.7, comment="温度(0-1)")
|
||||||
|
max_tokens = Column(Integer, default=1000, comment="最大 Token 数")
|
||||||
|
top_p = Column(Float, default=1.0, comment="Top P 参数")
|
||||||
|
|
||||||
|
# 状态控制
|
||||||
|
is_default = Column(Boolean, default=False, comment="是否为默认模型")
|
||||||
|
is_active = Column(Boolean, default=True, comment="是否启用")
|
||||||
|
|
||||||
|
# 描述信息
|
||||||
|
description = Column(Text, comment="模型描述")
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<AIModelConfig(id={self.id}, name='{self.model_name}', provider='{self.provider}')>"
|
||||||
49
backend/app/models/base.py
Normal file
49
backend/app/models/base.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
数据库基础配置
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 创建数据库引擎
|
||||||
|
engine = create_engine(
|
||||||
|
settings.get_database_url,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=20,
|
||||||
|
max_overflow=40,
|
||||||
|
echo=settings.DEBUG,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建会话工厂
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
# 创建基类
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db():
|
||||||
|
"""获取数据库会话的上下文管理器"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Database error: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""初始化数据库表"""
|
||||||
|
from . import virtual_user, interaction, token_usage, system_config, ai_model, news_cache
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info("Database tables created successfully")
|
||||||
68
backend/app/models/interaction.py
Normal file
68
backend/app/models/interaction.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
互动记录模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Enum, Text, ForeignKey, Boolean, Float
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionType(str, enum.Enum):
|
||||||
|
"""互动类型枚举"""
|
||||||
|
COMMENT = "comment" # 评论
|
||||||
|
REPLY = "reply" # 回复
|
||||||
|
LIKE = "like" # 点赞
|
||||||
|
FAVORITE = "favorite" # 收藏
|
||||||
|
SHARE = "share" # 转发
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionStatus(str, enum.Enum):
|
||||||
|
"""互动状态枚举"""
|
||||||
|
PENDING = "pending" # 待执行
|
||||||
|
SUCCESS = "success" # 成功
|
||||||
|
FAILED = "failed" # 失败
|
||||||
|
RETRYING = "retrying" # 重试中
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionRecord(Base):
|
||||||
|
"""互动记录表"""
|
||||||
|
__tablename__ = "interaction_records"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True, comment="记录 ID")
|
||||||
|
|
||||||
|
# 关联信息
|
||||||
|
virtual_user_id = Column(Integer, ForeignKey("virtual_users.id"), nullable=False, index=True, comment="虚拟用户 ID")
|
||||||
|
virtual_user = relationship("VirtualUser", backref="interaction_records")
|
||||||
|
|
||||||
|
news_id = Column(String(100), nullable=False, index=True, comment="新闻 ID")
|
||||||
|
news_title = Column(String(500), comment="新闻标题")
|
||||||
|
|
||||||
|
# 互动内容
|
||||||
|
interaction_type = Column(Enum(InteractionType), nullable=False, comment="互动类型")
|
||||||
|
content = Column(Text, comment="互动内容(评论/回复的文本)")
|
||||||
|
target_comment_id = Column(String(100), comment="目标评论 ID(回复时使用)")
|
||||||
|
|
||||||
|
# 执行状态
|
||||||
|
status = Column(Enum(InteractionStatus), default=InteractionStatus.PENDING, comment="执行状态")
|
||||||
|
retry_count = Column(Integer, default=0, comment="重试次数")
|
||||||
|
error_message = Column(Text, comment="错误信息(失败时)")
|
||||||
|
|
||||||
|
# AI 相关信息
|
||||||
|
ai_model_used = Column(String(100), comment="使用的 AI 模型")
|
||||||
|
tokens_used = Column(Integer, default=0, comment="消耗的 Token 数")
|
||||||
|
prompt_content = Column(Text, comment="发送给 AI 的提示词")
|
||||||
|
ai_response = Column(Text, comment="AI 返回的内容")
|
||||||
|
|
||||||
|
# 接口响应
|
||||||
|
api_response = Column(Text, comment="会会接口返回的原始响应")
|
||||||
|
api_request_id = Column(String(200), comment="接口请求 ID")
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
execution_time = Column(DateTime, server_default=func.now(), comment="执行时间")
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<InteractionRecord(id={self.id}, user_id={self.virtual_user_id}, type='{self.interaction_type}', status='{self.status}')>"
|
||||||
45
backend/app/models/news_cache.py
Normal file
45
backend/app/models/news_cache.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
新闻缓存模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, Date
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NewsCache(Base):
|
||||||
|
"""新闻缓存表"""
|
||||||
|
__tablename__ = "news_cache"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True, comment="缓存 ID")
|
||||||
|
|
||||||
|
# 新闻基本信息
|
||||||
|
news_id = Column(String(100), unique=True, nullable=False, index=True, comment="新闻 ID(来自会会接口)")
|
||||||
|
title = Column(String(500), nullable=False, comment="新闻标题")
|
||||||
|
summary = Column(Text, comment="新闻摘要")
|
||||||
|
content = Column(Text, comment="新闻内容")
|
||||||
|
|
||||||
|
# 来源信息
|
||||||
|
source = Column(String(200), comment="来源")
|
||||||
|
author = Column(String(100), comment="作者")
|
||||||
|
publish_time = Column(DateTime, comment="发布时间")
|
||||||
|
|
||||||
|
# 分类标签
|
||||||
|
category = Column(String(100), comment="分类")
|
||||||
|
tags = Column(String(500), comment="标签(逗号分隔)")
|
||||||
|
|
||||||
|
# 互动统计
|
||||||
|
view_count = Column(Integer, default=0, comment="阅读数")
|
||||||
|
comment_count = Column(Integer, default=0, comment="评论数")
|
||||||
|
like_count = Column(Integer, default=0, comment="点赞数")
|
||||||
|
|
||||||
|
# 缓存状态
|
||||||
|
is_cached = Column(Boolean, default=True, comment="是否已缓存")
|
||||||
|
cache_date = Column(Date, server_default=func.now(), index=True, comment="缓存日期")
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<NewsCache(id={self.id}, news_id='{self.news_id}', title='{self.title[:30]}...')>"
|
||||||
32
backend/app/models/system_config.py
Normal file
32
backend/app/models/system_config.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
系统配置模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, JSON
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfig(Base):
|
||||||
|
"""系统配置表"""
|
||||||
|
__tablename__ = "system_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True, comment="配置 ID")
|
||||||
|
|
||||||
|
# 配置键值
|
||||||
|
config_key = Column(String(100), unique=True, nullable=False, index=True, comment="配置键")
|
||||||
|
config_value = Column(JSON, nullable=False, comment="配置值(JSON 格式)")
|
||||||
|
config_type = Column(String(50), comment="配置类型(schedule/limit/probability等)")
|
||||||
|
|
||||||
|
# 描述信息
|
||||||
|
description = Column(Text, comment="配置描述")
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
is_active = Column(Boolean, default=True, comment="是否启用")
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<SystemConfig(id={self.id}, key='{self.config_key}')>"
|
||||||
36
backend/app/models/token_usage.py
Normal file
36
backend/app/models/token_usage.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Token 使用记录模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Date
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsage(Base):
|
||||||
|
"""Token 使用记录表"""
|
||||||
|
__tablename__ = "token_usages"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True, comment="记录 ID")
|
||||||
|
|
||||||
|
# 关联信息
|
||||||
|
virtual_user_id = Column(Integer, ForeignKey("virtual_users.id"), nullable=True, index=True, comment="虚拟用户 ID(可为空,系统级消耗)")
|
||||||
|
interaction_id = Column(Integer, ForeignKey("interaction_records.id"), nullable=True, comment="互动记录 ID")
|
||||||
|
|
||||||
|
# Token 信息
|
||||||
|
tokens_used = Column(Integer, nullable=False, comment="使用的 Token 数量")
|
||||||
|
tokens_prompt = Column(Integer, default=0, comment="提示词 Token 数")
|
||||||
|
tokens_completion = Column(Integer, default=0, comment="完成响应 Token 数")
|
||||||
|
|
||||||
|
# AI 模型信息
|
||||||
|
ai_model = Column(String(100), nullable=False, comment="使用的 AI 模型")
|
||||||
|
action_type = Column(String(50), comment="操作类型(generate_comment/generate_reply 等)")
|
||||||
|
|
||||||
|
# 日期分区(便于统计)
|
||||||
|
usage_date = Column(Date, server_default=func.now(), index=True, comment="使用日期")
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<TokenUsage(id={self.id}, tokens={self.tokens_used}, model='{self.ai_model}')>"
|
||||||
91
backend/app/models/virtual_user.py
Normal file
91
backend/app/models/virtual_user.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
虚拟用户模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Enum, Text, JSON, Float
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from enum import Enum as PyEnum
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityLevel(str, enum.Enum):
|
||||||
|
"""活跃度枚举"""
|
||||||
|
LOW = "low" # 低:每日 1-2 次
|
||||||
|
MEDIUM = "medium" # 中:每日 2-5 次
|
||||||
|
HIGH = "high" # 高:每日 5-10 次
|
||||||
|
|
||||||
|
|
||||||
|
class UserStatus(str, enum.Enum):
|
||||||
|
"""用户状态枚举"""
|
||||||
|
ACTIVE = "active" # 启用
|
||||||
|
DISABLED = "disabled" # 禁用
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualUser(Base):
|
||||||
|
"""虚拟用户表"""
|
||||||
|
__tablename__ = "virtual_users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True, comment="用户 ID")
|
||||||
|
|
||||||
|
# 基本信息
|
||||||
|
username = Column(String(100), unique=True, nullable=False, index=True, comment="用户名(账号)")
|
||||||
|
password = Column(String(200), nullable=False, comment="密码(加密存储)")
|
||||||
|
nickname = Column(String(100), nullable=False, comment="昵称")
|
||||||
|
avatar_url = Column(String(500), comment="头像 URL")
|
||||||
|
|
||||||
|
# 人格特征
|
||||||
|
writing_style = Column(String(50), comment="写作风格")
|
||||||
|
activity_level = Column(Enum(ActivityLevel), default=ActivityLevel.MEDIUM, comment="活跃度")
|
||||||
|
persona_description = Column(Text, comment="人格描述(AI 生成)")
|
||||||
|
|
||||||
|
# 状态控制
|
||||||
|
status = Column(Enum(UserStatus), default=UserStatus.ACTIVE, comment="状态")
|
||||||
|
is_logged_in = Column(Boolean, default=False, comment="是否已登录")
|
||||||
|
session_token = Column(String(500), comment="会话 Token(登录后)")
|
||||||
|
token_expire_time = Column(DateTime, comment="Token 过期时间")
|
||||||
|
|
||||||
|
# 互动统计
|
||||||
|
total_interactions = Column(Integer, default=0, comment="总互动次数")
|
||||||
|
today_comments = Column(Integer, default=0, comment="今日评论数")
|
||||||
|
today_replies = Column(Integer, default=0, comment="今日回复数")
|
||||||
|
last_interaction_time = Column(DateTime, comment="最后互动时间")
|
||||||
|
|
||||||
|
# 扩展信息
|
||||||
|
extra_info = Column(JSON, default=dict, comment="扩展信息")
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<VirtualUser(id={self.id}, nickname='{self.nickname}', status='{self.status}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualUserPersona(Base):
|
||||||
|
"""虚拟用户人格模板表"""
|
||||||
|
__tablename__ = "virtual_user_personas"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True, comment="ID")
|
||||||
|
|
||||||
|
# 人格特征
|
||||||
|
name = Column(String(100), unique=True, nullable=False, comment="人格名称")
|
||||||
|
description = Column(Text, comment="人格描述")
|
||||||
|
|
||||||
|
# 风格配置
|
||||||
|
writing_styles = Column(JSON, comment="写作风格列表")
|
||||||
|
personality_traits = Column(JSON, comment="性格特征列表")
|
||||||
|
speech_patterns = Column(JSON, comment="说话模式列表")
|
||||||
|
|
||||||
|
# AI 提示词
|
||||||
|
system_prompt = Column(Text, comment="系统提示词(用于 AI 生成)")
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
is_active = Column(Boolean, default=True, comment="是否启用")
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<VirtualUserPersona(id={self.id}, name='{self.name}')>"
|
||||||
53
backend/app/schemas/__init__.py
Normal file
53
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
Pydantic Schema 定义
|
||||||
|
"""
|
||||||
|
from .virtual_user import (
|
||||||
|
VirtualUserCreate,
|
||||||
|
VirtualUserUpdate,
|
||||||
|
VirtualUserResponse,
|
||||||
|
VirtualUserListResponse,
|
||||||
|
VirtualUserGenerateRequest,
|
||||||
|
VirtualUserImportRequest,
|
||||||
|
ActivityLevel,
|
||||||
|
UserStatus,
|
||||||
|
)
|
||||||
|
from .interaction import (
|
||||||
|
InteractionRecordResponse,
|
||||||
|
InteractionRecordListResponse,
|
||||||
|
InteractionType,
|
||||||
|
InteractionStatus,
|
||||||
|
)
|
||||||
|
from .token_usage import TokenUsageResponse, TokenUsageStats
|
||||||
|
from .system_config import SystemConfigResponse, SystemConfigUpdate
|
||||||
|
from .ai_model import AIModelConfigCreate, AIModelConfigUpdate, AIModelConfigResponse
|
||||||
|
from .dashboard import DashboardStats, DashboardTokenStats
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Virtual User
|
||||||
|
"VirtualUserCreate",
|
||||||
|
"VirtualUserUpdate",
|
||||||
|
"VirtualUserResponse",
|
||||||
|
"VirtualUserListResponse",
|
||||||
|
"VirtualUserGenerateRequest",
|
||||||
|
"VirtualUserImportRequest",
|
||||||
|
"ActivityLevel",
|
||||||
|
"UserStatus",
|
||||||
|
# Interaction
|
||||||
|
"InteractionRecordResponse",
|
||||||
|
"InteractionRecordListResponse",
|
||||||
|
"InteractionType",
|
||||||
|
"InteractionStatus",
|
||||||
|
# Token Usage
|
||||||
|
"TokenUsageResponse",
|
||||||
|
"TokenUsageStats",
|
||||||
|
# System Config
|
||||||
|
"SystemConfigResponse",
|
||||||
|
"SystemConfigUpdate",
|
||||||
|
# AI Model
|
||||||
|
"AIModelConfigCreate",
|
||||||
|
"AIModelConfigUpdate",
|
||||||
|
"AIModelConfigResponse",
|
||||||
|
# Dashboard
|
||||||
|
"DashboardStats",
|
||||||
|
"DashboardTokenStats",
|
||||||
|
]
|
||||||
64
backend/app/schemas/ai_model.py
Normal file
64
backend/app/schemas/ai_model.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
AI 模型配置相关 Schema
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelConfigBase(BaseModel):
|
||||||
|
"""AI 模型配置基础 Schema"""
|
||||||
|
model_name: str = Field(..., description="模型名称", max_length=100)
|
||||||
|
provider: str = Field(..., description="提供商", max_length=50)
|
||||||
|
display_name: Optional[str] = Field(None, description="显示名称", max_length=200)
|
||||||
|
api_url: str = Field(..., description="API 地址", max_length=500)
|
||||||
|
api_key: str = Field(..., description="API Key", max_length=500)
|
||||||
|
temperature: float = Field(0.7, description="温度", ge=0, le=1)
|
||||||
|
max_tokens: int = Field(1000, description="最大 Token 数", ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelConfigCreate(AIModelConfigBase):
|
||||||
|
"""创建 AI 模型配置请求"""
|
||||||
|
description: Optional[str] = Field(None, description="模型描述")
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelConfigUpdate(BaseModel):
|
||||||
|
"""更新 AI 模型配置请求"""
|
||||||
|
display_name: Optional[str] = Field(None, description="显示名称", max_length=200)
|
||||||
|
api_url: Optional[str] = Field(None, description="API 地址", max_length=500)
|
||||||
|
api_key: Optional[str] = Field(None, description="API Key", max_length=500)
|
||||||
|
temperature: Optional[float] = Field(None, description="温度", ge=0, le=1)
|
||||||
|
max_tokens: Optional[int] = Field(None, description="最大 Token 数", ge=1)
|
||||||
|
is_default: Optional[bool] = Field(None, description="是否为默认模型")
|
||||||
|
is_active: Optional[bool] = Field(None, description="是否启用")
|
||||||
|
description: Optional[str] = Field(None, description="模型描述")
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelConfigResponse(AIModelConfigBase):
|
||||||
|
"""AI 模型配置响应"""
|
||||||
|
id: int
|
||||||
|
api_version: Optional[str]
|
||||||
|
top_p: float
|
||||||
|
is_default: bool
|
||||||
|
is_active: bool
|
||||||
|
description: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelTestRequest(BaseModel):
|
||||||
|
"""AI 模型测试请求"""
|
||||||
|
model_id: int = Field(..., description="模型 ID")
|
||||||
|
test_prompt: str = Field(..., description="测试提示词", min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelTestResponse(BaseModel):
|
||||||
|
"""AI 模型测试响应"""
|
||||||
|
success: bool
|
||||||
|
content: Optional[str]
|
||||||
|
tokens_used: int
|
||||||
|
cost_time: float
|
||||||
|
error_message: Optional[str]
|
||||||
55
backend/app/schemas/dashboard.py
Normal file
55
backend/app/schemas/dashboard.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
控制台仪表盘相关 Schema
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class CoreStats(BaseModel):
|
||||||
|
"""核心指标统计"""
|
||||||
|
total_users: int = Field(0, description="虚拟用户总数")
|
||||||
|
active_users: int = Field(0, description="已启用用户数")
|
||||||
|
disabled_users: int = Field(0, description="已禁用用户数")
|
||||||
|
|
||||||
|
today_comments: int = Field(0, description="今日评论数")
|
||||||
|
today_replies: int = Field(0, description="今日回复数")
|
||||||
|
today_likes: int = Field(0, description="今日点赞数")
|
||||||
|
today_favorites: int = Field(0, description="今日收藏数")
|
||||||
|
today_shares: int = Field(0, description="今日转发数")
|
||||||
|
|
||||||
|
yesterday_comments: int = Field(0, description="昨日评论数")
|
||||||
|
yesterday_replies: int = Field(0, description="昨日回复数")
|
||||||
|
|
||||||
|
month_tokens: int = Field(0, description="当月 Token 消耗")
|
||||||
|
today_tokens: int = Field(0, description="今日 Token 消耗")
|
||||||
|
remaining_tokens: int = Field(0, description="今日剩余 Token")
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardTokenStats(BaseModel):
|
||||||
|
"""Token 统计"""
|
||||||
|
today_used: int = Field(0, description="今日已用")
|
||||||
|
today_limit: int = Field(0, description="今日限额")
|
||||||
|
today_remaining: int = Field(0, description="今日剩余")
|
||||||
|
usage_percentage: float = Field(0, description="使用百分比")
|
||||||
|
|
||||||
|
|
||||||
|
class DailyUsageItem(BaseModel):
|
||||||
|
"""每日使用项"""
|
||||||
|
date: str
|
||||||
|
tokens: int
|
||||||
|
comments: int
|
||||||
|
replies: int
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlyUsageItem(BaseModel):
|
||||||
|
"""每月使用项"""
|
||||||
|
month: str
|
||||||
|
tokens: int
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardStats(BaseModel):
|
||||||
|
"""控制台统计数据"""
|
||||||
|
core_stats: CoreStats
|
||||||
|
daily_token_usages: List[DailyUsageItem] = Field(default_factory=list)
|
||||||
|
monthly_token_usages: List[MonthlyUsageItem] = Field(default_factory=list)
|
||||||
|
recent_interactions: List[dict] = Field(default_factory=list)
|
||||||
63
backend/app/schemas/interaction.py
Normal file
63
backend/app/schemas/interaction.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
互动记录相关 Schema
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionType(str, Enum):
|
||||||
|
"""互动类型枚举"""
|
||||||
|
COMMENT = "comment"
|
||||||
|
REPLY = "reply"
|
||||||
|
LIKE = "like"
|
||||||
|
FAVORITE = "favorite"
|
||||||
|
SHARE = "share"
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionStatus(str, Enum):
|
||||||
|
"""互动状态枚举"""
|
||||||
|
PENDING = "pending"
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
RETRYING = "retrying"
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionRecordBase(BaseModel):
|
||||||
|
"""互动记录基础 Schema"""
|
||||||
|
virtual_user_id: int = Field(..., description="虚拟用户 ID")
|
||||||
|
news_id: str = Field(..., description="新闻 ID")
|
||||||
|
interaction_type: InteractionType = Field(..., description="互动类型")
|
||||||
|
content: Optional[str] = Field(None, description="互动内容")
|
||||||
|
target_comment_id: Optional[str] = Field(None, description="目标评论 ID")
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionRecordResponse(InteractionRecordBase):
|
||||||
|
"""互动记录响应"""
|
||||||
|
id: int
|
||||||
|
news_title: Optional[str]
|
||||||
|
status: InteractionStatus
|
||||||
|
retry_count: int
|
||||||
|
error_message: Optional[str]
|
||||||
|
ai_model_used: Optional[str]
|
||||||
|
tokens_used: int
|
||||||
|
execution_time: datetime
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionRecordListResponse(BaseModel):
|
||||||
|
"""互动记录列表响应"""
|
||||||
|
total: int
|
||||||
|
items: List[InteractionRecordResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionExecuteRequest(BaseModel):
|
||||||
|
"""执行互动请求"""
|
||||||
|
virtual_user_id: int = Field(..., description="虚拟用户 ID")
|
||||||
|
news_id: Optional[str] = Field(None, description="新闻 ID(不传则随机选择)")
|
||||||
|
interaction_type: Optional[InteractionType] = Field(None, description="互动类型(不传则随机)")
|
||||||
|
force_execute: bool = Field(False, description="是否强制执行(忽略限额)")
|
||||||
60
backend/app/schemas/system_config.py
Normal file
60
backend/app/schemas/system_config.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
系统配置相关 Schema
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfigBase(BaseModel):
|
||||||
|
"""系统配置基础 Schema"""
|
||||||
|
config_key: str = Field(..., description="配置键", max_length=100)
|
||||||
|
config_value: Dict[str, Any] = Field(..., description="配置值")
|
||||||
|
config_type: Optional[str] = Field(None, description="配置类型", max_length=50)
|
||||||
|
description: Optional[str] = Field(None, description="配置描述")
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfigCreate(SystemConfigBase):
|
||||||
|
"""创建系统配置请求"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfigUpdate(BaseModel):
|
||||||
|
"""更新系统配置请求"""
|
||||||
|
config_value: Optional[Dict[str, Any]] = Field(None, description="配置值")
|
||||||
|
description: Optional[str] = Field(None, description="配置描述")
|
||||||
|
is_active: Optional[bool] = Field(None, description="是否启用")
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfigResponse(SystemConfigBase):
|
||||||
|
"""系统配置响应"""
|
||||||
|
id: int
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleConfig(BaseModel):
|
||||||
|
"""调度配置"""
|
||||||
|
task_start_hour: int = Field(9, description="活动开始时间", ge=0, le=23)
|
||||||
|
task_end_hour: int = Field(22, description="活动结束时间", ge=0, le=23)
|
||||||
|
task_interval_min: int = Field(10, description="最小间隔(分钟)", ge=1)
|
||||||
|
task_interval_max: int = Field(30, description="最大间隔(分钟)", ge=1)
|
||||||
|
is_task_running: bool = Field(False, description="任务是否运行中")
|
||||||
|
|
||||||
|
|
||||||
|
class LimitConfig(BaseModel):
|
||||||
|
"""限额配置"""
|
||||||
|
max_tokens_per_day: int = Field(10000, description="每日 Token 上限", ge=0)
|
||||||
|
max_comments_per_user_per_day: int = Field(20, description="单用户每日最大评论数", ge=0)
|
||||||
|
max_replies_per_user_per_day: int = Field(10, description="单用户每日最大回复数", ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ProbabilityConfig(BaseModel):
|
||||||
|
"""概率配置"""
|
||||||
|
like_probability: float = Field(0.8, description="点赞概率", ge=0, le=1)
|
||||||
|
favorite_probability: float = Field(0.5, description="收藏概率", ge=0, le=1)
|
||||||
|
share_probability: float = Field(0.3, description="转发概率", ge=0, le=1)
|
||||||
54
backend/app/schemas/token_usage.py
Normal file
54
backend/app/schemas/token_usage.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Token 使用相关 Schema
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsageBase(BaseModel):
|
||||||
|
"""Token 使用基础 Schema"""
|
||||||
|
tokens_used: int = Field(..., description="使用的 Token 数量")
|
||||||
|
ai_model: str = Field(..., description="使用的 AI 模型")
|
||||||
|
action_type: Optional[str] = Field(None, description="操作类型")
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsageResponse(TokenUsageBase):
|
||||||
|
"""Token 使用响应"""
|
||||||
|
id: int
|
||||||
|
virtual_user_id: Optional[int]
|
||||||
|
interaction_id: Optional[int]
|
||||||
|
tokens_prompt: int
|
||||||
|
tokens_completion: int
|
||||||
|
usage_date: date
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsageStats(BaseModel):
|
||||||
|
"""Token 使用统计"""
|
||||||
|
today_tokens: int = Field(0, description="今日 Token 数")
|
||||||
|
yesterday_tokens: int = Field(0, description="昨日 Token 数")
|
||||||
|
month_tokens: int = Field(0, description="当月 Token 数")
|
||||||
|
remaining_tokens: int = Field(0, description="剩余 Token 数")
|
||||||
|
total_limit: int = Field(0, description="总限额")
|
||||||
|
|
||||||
|
|
||||||
|
class DailyTokenUsage(BaseModel):
|
||||||
|
"""每日 Token 使用"""
|
||||||
|
date: str
|
||||||
|
tokens: int
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlyTokenUsage(BaseModel):
|
||||||
|
"""每月 Token 使用"""
|
||||||
|
month: str
|
||||||
|
tokens: int
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsageChartResponse(BaseModel):
|
||||||
|
"""Token 使用图表响应"""
|
||||||
|
daily_usages: List[DailyTokenUsage]
|
||||||
|
monthly_usages: List[MonthlyTokenUsage]
|
||||||
86
backend/app/schemas/virtual_user.py
Normal file
86
backend/app/schemas/virtual_user.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
虚拟用户相关 Schema
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityLevel(str, Enum):
|
||||||
|
"""活跃度枚举"""
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
|
||||||
|
|
||||||
|
class UserStatus(str, Enum):
|
||||||
|
"""用户状态枚举"""
|
||||||
|
ACTIVE = "active"
|
||||||
|
DISABLED = "disabled"
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualUserBase(BaseModel):
|
||||||
|
"""虚拟用户基础 Schema"""
|
||||||
|
nickname: str = Field(..., description="昵称", min_length=1, max_length=100)
|
||||||
|
username: str = Field(..., description="用户名(账号)", min_length=1, max_length=100)
|
||||||
|
password: str = Field(..., description="密码", min_length=1)
|
||||||
|
avatar_url: Optional[str] = Field(None, description="头像 URL", max_length=500)
|
||||||
|
writing_style: Optional[str] = Field(None, description="写作风格", max_length=50)
|
||||||
|
activity_level: ActivityLevel = Field(default=ActivityLevel.MEDIUM, description="活跃度")
|
||||||
|
persona_description: Optional[str] = Field(None, description="人格描述")
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualUserCreate(VirtualUserBase):
|
||||||
|
"""创建虚拟用户请求"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualUserUpdate(BaseModel):
|
||||||
|
"""更新虚拟用户请求"""
|
||||||
|
nickname: Optional[str] = Field(None, description="昵称", min_length=1, max_length=100)
|
||||||
|
password: Optional[str] = Field(None, description="密码", min_length=1)
|
||||||
|
avatar_url: Optional[str] = Field(None, description="头像 URL", max_length=500)
|
||||||
|
writing_style: Optional[str] = Field(None, description="写作风格", max_length=50)
|
||||||
|
activity_level: Optional[ActivityLevel] = Field(None, description="活跃度")
|
||||||
|
persona_description: Optional[str] = Field(None, description="人格描述")
|
||||||
|
status: Optional[UserStatus] = Field(None, description="状态")
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualUserResponse(VirtualUserBase):
|
||||||
|
"""虚拟用户响应"""
|
||||||
|
id: int
|
||||||
|
status: UserStatus
|
||||||
|
is_logged_in: bool
|
||||||
|
total_interactions: int
|
||||||
|
today_comments: int
|
||||||
|
today_replies: int
|
||||||
|
last_interaction_time: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualUserListResponse(BaseModel):
|
||||||
|
"""虚拟用户列表响应"""
|
||||||
|
total: int
|
||||||
|
items: List[VirtualUserResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualUserGenerateRequest(BaseModel):
|
||||||
|
"""生成虚拟用户请求"""
|
||||||
|
count: int = Field(1, description="生成数量", ge=1, le=100)
|
||||||
|
writing_styles: Optional[List[str]] = Field(None, description="写作风格列表")
|
||||||
|
activity_levels: Optional[List[ActivityLevel]] = Field(
|
||||||
|
[ActivityLevel.LOW, ActivityLevel.MEDIUM, ActivityLevel.HIGH],
|
||||||
|
description="活跃度级别列表"
|
||||||
|
)
|
||||||
|
generate_persona: bool = Field(True, description="是否生成 AI 人格描述")
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualUserImportRequest(BaseModel):
|
||||||
|
"""导入虚拟用户请求"""
|
||||||
|
users: List[Dict[str, Any]] = Field(..., description="用户数据列表")
|
||||||
|
generate_persona: bool = Field(True, description="是否为导入的用户生成 AI 人格描述")
|
||||||
18
backend/app/services/__init__.py
Normal file
18
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
服务层模块初始化
|
||||||
|
"""
|
||||||
|
from .huihui_api import HuihuiAPIService
|
||||||
|
from .ai_service import AIService
|
||||||
|
from .virtual_user_service import VirtualUserService
|
||||||
|
from .interaction_service import InteractionService
|
||||||
|
from .token_service import TokenService
|
||||||
|
from .scheduler_service import SchedulerService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HuihuiAPIService",
|
||||||
|
"AIService",
|
||||||
|
"VirtualUserService",
|
||||||
|
"InteractionService",
|
||||||
|
"TokenService",
|
||||||
|
"SchedulerService",
|
||||||
|
]
|
||||||
301
backend/app/services/ai_service.py
Normal file
301
backend/app/services/ai_service.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
AI 大模型对接服务
|
||||||
|
支持 OpenAI、智谱、百度文心、阿里通义等主流大模型
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AIService:
|
||||||
|
"""AI 服务类"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._client_cache: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def generate_comment(
|
||||||
|
self,
|
||||||
|
news_content: str,
|
||||||
|
writing_style: str,
|
||||||
|
persona_description: Optional[str] = None,
|
||||||
|
model_config: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
AI 生成评论
|
||||||
|
:param news_content: 新闻内容
|
||||||
|
:param writing_style: 写作风格
|
||||||
|
:param persona_description: 人格描述
|
||||||
|
:param model_config: 模型配置
|
||||||
|
:return: 生成结果(包含 content, tokens_used 等)
|
||||||
|
"""
|
||||||
|
prompt = self._build_comment_prompt(
|
||||||
|
news_content,
|
||||||
|
writing_style,
|
||||||
|
persona_description
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self._call_ai_api(prompt, model_config)
|
||||||
|
|
||||||
|
async def generate_reply(
|
||||||
|
self,
|
||||||
|
original_comment: str,
|
||||||
|
news_content: str,
|
||||||
|
writing_style: str,
|
||||||
|
persona_description: Optional[str] = None,
|
||||||
|
model_config: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
AI 生成回复
|
||||||
|
:param original_comment: 原评论
|
||||||
|
:param news_content: 新闻内容
|
||||||
|
:param writing_style: 写作风格
|
||||||
|
:param persona_description: 人格描述
|
||||||
|
:param model_config: 模型配置
|
||||||
|
:return: 生成结果
|
||||||
|
"""
|
||||||
|
prompt = self._build_reply_prompt(
|
||||||
|
original_comment,
|
||||||
|
news_content,
|
||||||
|
writing_style,
|
||||||
|
persona_description
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self._call_ai_api(prompt, model_config)
|
||||||
|
|
||||||
|
def _build_comment_prompt(
|
||||||
|
self,
|
||||||
|
news_content: str,
|
||||||
|
writing_style: str,
|
||||||
|
persona_description: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""构建评论提示词"""
|
||||||
|
base_prompt = f"""你是一位虚拟用户,请根据以下要求写一条简短的评论:
|
||||||
|
|
||||||
|
写作风格:{writing_style}
|
||||||
|
"""
|
||||||
|
|
||||||
|
if persona_description:
|
||||||
|
base_prompt += f"\n人格特征:{persona_description}\n"
|
||||||
|
|
||||||
|
base_prompt += f"""
|
||||||
|
新闻内容:
|
||||||
|
{news_content[:1000]} # 限制长度
|
||||||
|
|
||||||
|
请写一条 50-100 字的评论,要符合你的写作风格和人格特征。直接输出评论内容,不要有其他说明。"""
|
||||||
|
|
||||||
|
return base_prompt
|
||||||
|
|
||||||
|
def _build_reply_prompt(
|
||||||
|
self,
|
||||||
|
original_comment: str,
|
||||||
|
news_content: str,
|
||||||
|
writing_style: str,
|
||||||
|
persona_description: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""构建回复提示词"""
|
||||||
|
base_prompt = f"""你是一位虚拟用户,请根据以下要求回复另一条评论:
|
||||||
|
|
||||||
|
写作风格:{writing_style}
|
||||||
|
"""
|
||||||
|
|
||||||
|
if persona_description:
|
||||||
|
base_prompt += f"\n人格特征:{persona_description}\n"
|
||||||
|
|
||||||
|
base_prompt += f"""
|
||||||
|
新闻内容:
|
||||||
|
{news_content[:500]}
|
||||||
|
|
||||||
|
原评论:
|
||||||
|
{original_comment}
|
||||||
|
|
||||||
|
请写一条 30-80 字的回复,要符合你的写作风格和人格特征。直接输出回复内容,不要有其他说明。"""
|
||||||
|
|
||||||
|
return base_prompt
|
||||||
|
|
||||||
|
async def _call_ai_api(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
model_config: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
调用 AI API(根据 model_config 中的 provider 选择对应模型)
|
||||||
|
:param prompt: 提示词
|
||||||
|
:param model_config: 模型配置
|
||||||
|
:return: 生成结果
|
||||||
|
"""
|
||||||
|
if not model_config:
|
||||||
|
# 使用默认配置(需要从数据库加载)
|
||||||
|
from app.models.ai_model import AIModelConfig
|
||||||
|
from app.models.base import get_db
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
default_model = db.query(AIModelConfig).filter(
|
||||||
|
AIModelConfig.is_default == True,
|
||||||
|
AIModelConfig.is_active == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not default_model:
|
||||||
|
logger.error("No default AI model configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"provider": default_model.provider,
|
||||||
|
"model_name": default_model.model_name,
|
||||||
|
"api_key": default_model.api_key,
|
||||||
|
"api_url": default_model.api_url,
|
||||||
|
"temperature": default_model.temperature,
|
||||||
|
"max_tokens": default_model.max_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
provider = model_config.get("provider", "").lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if provider == "openai":
|
||||||
|
return await self._call_openai(prompt, model_config)
|
||||||
|
elif provider == "zhipu":
|
||||||
|
return await self._call_zhipu(prompt, model_config)
|
||||||
|
elif provider in ["baidu", "wenxin"]:
|
||||||
|
return await self._call_baidu_wenxin(prompt, model_config)
|
||||||
|
elif provider in ["aliyun", "dashscope"]:
|
||||||
|
return await self._call_aliyun_dashscope(prompt, model_config)
|
||||||
|
else:
|
||||||
|
logger.error(f"Unsupported AI provider: {provider}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI API call error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _call_openai(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""调用 OpenAI API"""
|
||||||
|
try:
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
client = AsyncOpenAI(
|
||||||
|
api_key=config["api_key"],
|
||||||
|
base_url=config.get("api_url")
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=config.get("model_name", "gpt-3.5-turbo"),
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
temperature=config.get("temperature", 0.7),
|
||||||
|
max_tokens=config.get("max_tokens", 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
tokens_used = response.usage.total_tokens if response.usage else 0
|
||||||
|
|
||||||
|
logger.info(f"OpenAI generated content, tokens: {tokens_used}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"content": content,
|
||||||
|
"tokens_used": tokens_used,
|
||||||
|
"provider": "openai",
|
||||||
|
"model": config.get("model_name", "gpt-3.5-turbo")
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OpenAI API error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _call_zhipu(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""调用智谱 AI API"""
|
||||||
|
try:
|
||||||
|
from zhipuai import ZhipuAI
|
||||||
|
|
||||||
|
client = ZhipuAI(api_key=config["api_key"])
|
||||||
|
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model=config.get("model_name", "glm-4"),
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
temperature=config.get("temperature", 0.7),
|
||||||
|
max_tokens=config.get("max_tokens", 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
tokens_used = response.usage.total_tokens if response.usage else 0
|
||||||
|
|
||||||
|
logger.info(f"Zhipu AI generated content, tokens: {tokens_used}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"content": content,
|
||||||
|
"tokens_used": tokens_used,
|
||||||
|
"provider": "zhipu",
|
||||||
|
"model": config.get("model_name", "glm-4")
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Zhipu AI API error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _call_baidu_wenxin(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""调用百度文心一言 API"""
|
||||||
|
# TODO: 实现百度文心一言 API 调用
|
||||||
|
logger.warning("Baidu Wenxin API not implemented yet")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _call_aliyun_dashscope(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""调用阿里云通义千问 API"""
|
||||||
|
# TODO: 实现阿里云 DashScope API 调用
|
||||||
|
logger.warning("Aliyun DashScope API not implemented yet")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def test_model(
|
||||||
|
self,
|
||||||
|
model_config: Dict[str, Any],
|
||||||
|
test_prompt: str = "测试评论"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
测试模型配置
|
||||||
|
:param model_config: 模型配置
|
||||||
|
:param test_prompt: 测试提示词
|
||||||
|
:return: 测试结果
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
result = await self._call_ai_api(test_prompt, model_config)
|
||||||
|
|
||||||
|
cost_time = time.time() - start_time
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"content": result.get("content"),
|
||||||
|
"tokens_used": result.get("tokens_used", 0),
|
||||||
|
"cost_time": round(cost_time, 2),
|
||||||
|
"error_message": None
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"content": None,
|
||||||
|
"tokens_used": 0,
|
||||||
|
"cost_time": round(cost_time, 2),
|
||||||
|
"error_message": "Failed to generate content"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 创建全局服务实例
|
||||||
|
ai_service = AIService()
|
||||||
291
backend/app/services/huihui_api.py
Normal file
291
backend/app/services/huihui_api.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
"""
|
||||||
|
会会接口对接服务
|
||||||
|
基于 http://192.168.1.200:63120/doc.html 接口文档
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HuihuiAPIService:
|
||||||
|
"""会会 API 服务类"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = settings.HUIHUI_API_BASE
|
||||||
|
self.timeout = 30 # 秒
|
||||||
|
self._session_cache: Dict[str, httpx.AsyncClient] = {}
|
||||||
|
|
||||||
|
def _get_client(self, session_token: Optional[str] = None) -> httpx.AsyncClient:
|
||||||
|
"""获取 HTTP 客户端"""
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if session_token:
|
||||||
|
headers["Authorization"] = f"Bearer {session_token}"
|
||||||
|
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def login(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
用户登录
|
||||||
|
:param username: 用户名
|
||||||
|
:param password: 密码
|
||||||
|
:return: 登录响应(包含 session token)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._get_client() as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/login", # 实际接口路径需根据 doc.html 调整
|
||||||
|
json={"username": username, "password": password}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Login success for user: {username}")
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
logger.error(f"Login failed: {response.status_code} - {response.text}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Login error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_news_list(
|
||||||
|
self,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
category: Optional[str] = None
|
||||||
|
) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
获取新闻列表
|
||||||
|
:param page: 页码
|
||||||
|
:param page_size: 每页数量
|
||||||
|
:param category: 分类(可选)
|
||||||
|
:return: 新闻列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._get_client() as client:
|
||||||
|
params = {"page": page, "pageSize": page_size}
|
||||||
|
if category:
|
||||||
|
params["category"] = category
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/api/news/list", # 实际接口路径需根据 doc.html 调整
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
else:
|
||||||
|
logger.error(f"Get news list failed: {response.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get news list error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_news_detail(self, news_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取新闻详情
|
||||||
|
:param news_id: 新闻 ID
|
||||||
|
:return: 新闻详情
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._get_client() as client:
|
||||||
|
response = await client.get(f"/api/news/{news_id}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data")
|
||||||
|
else:
|
||||||
|
logger.error(f"Get news detail failed: {response.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get news detail error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def create_comment(
|
||||||
|
self,
|
||||||
|
news_id: str,
|
||||||
|
content: str,
|
||||||
|
session_token: str
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
创建评论
|
||||||
|
:param news_id: 新闻 ID
|
||||||
|
:param content: 评论内容
|
||||||
|
:param session_token: 会话 Token
|
||||||
|
:return: 评论结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._get_client(session_token) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/comment/create", # 实际接口路径需根据 doc.html 调整
|
||||||
|
json={"newsId": news_id, "content": content}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Comment created for news: {news_id}")
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
logger.error(f"Create comment failed: {response.status_code} - {response.text}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Create comment error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def create_reply(
|
||||||
|
self,
|
||||||
|
comment_id: str,
|
||||||
|
content: str,
|
||||||
|
session_token: str
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
创建回复
|
||||||
|
:param comment_id: 评论 ID
|
||||||
|
:param content: 回复内容
|
||||||
|
:param session_token: 会话 Token
|
||||||
|
:return: 回复结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._get_client(session_token) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/reply/create", # 实际接口路径需根据 doc.html 调整
|
||||||
|
json={"commentId": comment_id, "content": content}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Reply created for comment: {comment_id}")
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
logger.error(f"Create reply failed: {response.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Create reply error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def like_comment(
|
||||||
|
self,
|
||||||
|
comment_id: str,
|
||||||
|
session_token: str
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
点赞评论
|
||||||
|
:param comment_id: 评论 ID
|
||||||
|
:param session_token: 会话 Token
|
||||||
|
:return: 点赞结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._get_client(session_token) as client:
|
||||||
|
response = await client.post(f"/api/comment/{comment_id}/like")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Comment liked: {comment_id}")
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
logger.error(f"Like comment failed: {response.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Like comment error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def favorite_news(
|
||||||
|
self,
|
||||||
|
news_id: str,
|
||||||
|
session_token: str
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
收藏新闻
|
||||||
|
:param news_id: 新闻 ID
|
||||||
|
:param session_token: 会话 Token
|
||||||
|
:return: 收藏结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._get_client(session_token) as client:
|
||||||
|
response = await client.post(f"/api/news/{news_id}/favorite")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"News favorited: {news_id}")
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
logger.error(f"Favorite news failed: {response.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Favorite news error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def share_news(
|
||||||
|
self,
|
||||||
|
news_id: str,
|
||||||
|
session_token: str
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
转发新闻
|
||||||
|
:param news_id: 新闻 ID
|
||||||
|
:param session_token: 会话 Token
|
||||||
|
:return: 转发结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._get_client(session_token) as client:
|
||||||
|
response = await client.post(f"/api/news/{news_id}/share")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"News shared: {news_id}")
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
logger.error(f"Share news failed: {response.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Share news error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_comments(
|
||||||
|
self,
|
||||||
|
news_id: str,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20
|
||||||
|
) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
获取新闻评论列表
|
||||||
|
:param news_id: 新闻 ID
|
||||||
|
:param page: 页码
|
||||||
|
:param page_size: 每页数量
|
||||||
|
:return: 评论列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._get_client() as client:
|
||||||
|
params = {"page": page, "pageSize": page_size}
|
||||||
|
response = await client.get(
|
||||||
|
f"/api/news/{news_id}/comments",
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
else:
|
||||||
|
logger.error(f"Get comments failed: {response.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get comments error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# 创建全局服务实例
|
||||||
|
huihui_api_service = HuihuiAPIService()
|
||||||
408
backend/app/services/interaction_service.py
Normal file
408
backend/app/services/interaction_service.py
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
"""
|
||||||
|
互动执行服务
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, func
|
||||||
|
|
||||||
|
from app.models.virtual_user import VirtualUser, ActivityLevel, UserStatus
|
||||||
|
from app.models.interaction import InteractionRecord, InteractionType, InteractionStatus
|
||||||
|
from app.models.token_usage import TokenUsage
|
||||||
|
from app.models.news_cache import NewsCache
|
||||||
|
from app.services.huihui_api_service import huihui_api_service
|
||||||
|
from app.services.ai_service import ai_service
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionService:
|
||||||
|
"""互动执行服务类"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def execute_interaction(
|
||||||
|
self,
|
||||||
|
virtual_user_id: int,
|
||||||
|
interaction_type: Optional[InteractionType] = None,
|
||||||
|
news_id: Optional[str] = None
|
||||||
|
) -> Optional[InteractionRecord]:
|
||||||
|
"""
|
||||||
|
执行单次互动
|
||||||
|
:param virtual_user_id: 虚拟用户 ID
|
||||||
|
:param interaction_type: 互动类型(不传则随机)
|
||||||
|
:param news_id: 新闻 ID(不传则随机选择)
|
||||||
|
:return: 互动记录
|
||||||
|
"""
|
||||||
|
# 获取虚拟用户
|
||||||
|
user = self.db.query(VirtualUser).filter(VirtualUser.id == virtual_user_id).first()
|
||||||
|
if not user:
|
||||||
|
logger.error(f"Virtual user not found: {virtual_user_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查用户状态
|
||||||
|
if user.status != UserStatus.ACTIVE:
|
||||||
|
logger.warning(f"Virtual user is not active: {virtual_user_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查是否已登录
|
||||||
|
if not user.is_logged_in or not user.session_token:
|
||||||
|
logger.warning(f"Virtual user not logged in: {virtual_user_id}")
|
||||||
|
# TODO: 自动登录
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查今日限额
|
||||||
|
if not self._check_daily_limit(user, interaction_type):
|
||||||
|
logger.warning(f"Daily limit reached for user {virtual_user_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 选择新闻
|
||||||
|
if not news_id:
|
||||||
|
news_id = await self._select_news(user)
|
||||||
|
if not news_id:
|
||||||
|
logger.warning("No news available for interaction")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 获取新闻详情
|
||||||
|
news = self.db.query(NewsCache).filter(NewsCache.news_id == news_id).first()
|
||||||
|
if not news:
|
||||||
|
# 从 API 获取
|
||||||
|
news_detail = await huihui_api_service.get_news_detail(news_id)
|
||||||
|
if news_detail:
|
||||||
|
news = self._cache_news(news_detail)
|
||||||
|
|
||||||
|
if not news:
|
||||||
|
logger.error(f"Cannot get news detail: {news_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 确定互动类型
|
||||||
|
if not interaction_type:
|
||||||
|
interaction_type = self._random_interaction_type()
|
||||||
|
|
||||||
|
# 创建互动记录
|
||||||
|
record = InteractionRecord(
|
||||||
|
virtual_user_id=virtual_user_id,
|
||||||
|
news_id=news_id,
|
||||||
|
news_title=news.title if news else "",
|
||||||
|
interaction_type=interaction_type,
|
||||||
|
status=InteractionStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(record)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(record)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 执行互动
|
||||||
|
if interaction_type == InteractionType.COMMENT:
|
||||||
|
result = await self._execute_comment(user, news, record)
|
||||||
|
elif interaction_type == InteractionType.REPLY:
|
||||||
|
result = await self._execute_reply(user, news, record)
|
||||||
|
elif interaction_type == InteractionType.LIKE:
|
||||||
|
result = await self._execute_like(user, news, record)
|
||||||
|
elif interaction_type == InteractionType.FAVORITE:
|
||||||
|
result = await self._execute_favorite(user, news, record)
|
||||||
|
elif interaction_type == InteractionType.SHARE:
|
||||||
|
result = await self._execute_share(user, news, record)
|
||||||
|
else:
|
||||||
|
logger.error(f"Unknown interaction type: {interaction_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if result:
|
||||||
|
record.status = InteractionStatus.SUCCESS
|
||||||
|
record.api_response = str(result)
|
||||||
|
|
||||||
|
# 更新用户统计
|
||||||
|
user.total_interactions += 1
|
||||||
|
if interaction_type == InteractionType.COMMENT:
|
||||||
|
user.today_comments += 1
|
||||||
|
elif interaction_type == InteractionType.REPLY:
|
||||||
|
user.today_replies += 1
|
||||||
|
user.last_interaction_time = datetime.now()
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Interaction executed successfully: user={user.id}, type={interaction_type}")
|
||||||
|
return record
|
||||||
|
else:
|
||||||
|
record.status = InteractionStatus.FAILED
|
||||||
|
record.error_message = "API call failed"
|
||||||
|
self.db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Execute interaction error: {e}")
|
||||||
|
record.status = InteractionStatus.FAILED
|
||||||
|
record.error_message = str(e)
|
||||||
|
self.db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _execute_comment(
|
||||||
|
self,
|
||||||
|
user: VirtualUser,
|
||||||
|
news: NewsCache,
|
||||||
|
record: InteractionRecord
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""执行评论"""
|
||||||
|
# AI 生成评论内容
|
||||||
|
ai_result = await ai_service.generate_comment(
|
||||||
|
news_content=news.content or news.summary or news.title,
|
||||||
|
writing_style=user.writing_style or "普通",
|
||||||
|
persona_description=user.persona_description
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ai_result or not ai_result.get("content"):
|
||||||
|
logger.error("AI generate comment failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 记录 Token 使用
|
||||||
|
self._record_token_usage(
|
||||||
|
virtual_user_id=user.id,
|
||||||
|
interaction_id=record.id,
|
||||||
|
tokens_used=ai_result.get("tokens_used", 0),
|
||||||
|
ai_model=ai_result.get("model", "unknown"),
|
||||||
|
action_type="generate_comment",
|
||||||
|
tokens_prompt=ai_result.get("tokens_prompt", 0),
|
||||||
|
tokens_completion=ai_result.get("tokens_completion", 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用接口提交评论
|
||||||
|
result = await huihui_api_service.create_comment(
|
||||||
|
news_id=news.news_id,
|
||||||
|
content=ai_result["content"],
|
||||||
|
session_token=user.session_token
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
record.content = ai_result["content"]
|
||||||
|
record.tokens_used = ai_result.get("tokens_used", 0)
|
||||||
|
record.ai_model_used = ai_result.get("model")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _execute_reply(
|
||||||
|
self,
|
||||||
|
user: VirtualUser,
|
||||||
|
news: NewsCache,
|
||||||
|
record: InteractionRecord
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""执行回复"""
|
||||||
|
# 获取评论列表
|
||||||
|
comments = await huihui_api_service.get_comments(news_id=news.news_id)
|
||||||
|
|
||||||
|
if not comments or len(comments) == 0:
|
||||||
|
logger.warning(f"No comments available for news {news.news_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 随机选择一条评论进行回复
|
||||||
|
target_comment = random.choice(comments)
|
||||||
|
record.target_comment_id = target_comment.get("id")
|
||||||
|
|
||||||
|
# AI 生成回复内容
|
||||||
|
ai_result = await ai_service.generate_reply(
|
||||||
|
original_comment=target_comment.get("content", ""),
|
||||||
|
news_content=news.content or news.summary or news.title,
|
||||||
|
writing_style=user.writing_style or "普通",
|
||||||
|
persona_description=user.persona_description
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ai_result or not ai_result.get("content"):
|
||||||
|
logger.error("AI generate reply failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 记录 Token 使用
|
||||||
|
self._record_token_usage(
|
||||||
|
virtual_user_id=user.id,
|
||||||
|
interaction_id=record.id,
|
||||||
|
tokens_used=ai_result.get("tokens_used", 0),
|
||||||
|
ai_model=ai_result.get("model", "unknown"),
|
||||||
|
action_type="generate_reply"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用接口提交回复
|
||||||
|
result = await huihui_api_service.create_reply(
|
||||||
|
comment_id=target_comment.get("id"),
|
||||||
|
content=ai_result["content"],
|
||||||
|
session_token=user.session_token
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
record.content = ai_result["content"]
|
||||||
|
record.tokens_used = ai_result.get("tokens_used", 0)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _execute_like(
|
||||||
|
self,
|
||||||
|
user: VirtualUser,
|
||||||
|
news: NewsCache,
|
||||||
|
record: InteractionRecord
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""执行点赞"""
|
||||||
|
# 获取评论列表
|
||||||
|
comments = await huihui_api_service.get_comments(news_id=news.news_id)
|
||||||
|
|
||||||
|
if not comments or len(comments) == 0:
|
||||||
|
logger.warning(f"No comments available for like")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 随机选择一条评论点赞
|
||||||
|
target_comment = random.choice(comments)
|
||||||
|
record.target_comment_id = target_comment.get("id")
|
||||||
|
|
||||||
|
result = await huihui_api_service.like_comment(
|
||||||
|
comment_id=target_comment.get("id"),
|
||||||
|
session_token=user.session_token
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _execute_favorite(
|
||||||
|
self,
|
||||||
|
user: VirtualUser,
|
||||||
|
news: NewsCache,
|
||||||
|
record: InteractionRecord
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""执行收藏"""
|
||||||
|
result = await huihui_api_service.favorite_news(
|
||||||
|
news_id=news.news_id,
|
||||||
|
session_token=user.session_token
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _execute_share(
|
||||||
|
self,
|
||||||
|
user: VirtualUser,
|
||||||
|
news: NewsCache,
|
||||||
|
record: InteractionRecord
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""执行转发"""
|
||||||
|
result = await huihui_api_service.share_news(
|
||||||
|
news_id=news.news_id,
|
||||||
|
session_token=user.session_token
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _check_daily_limit(
|
||||||
|
self,
|
||||||
|
user: VirtualUser,
|
||||||
|
interaction_type: Optional[InteractionType]
|
||||||
|
) -> bool:
|
||||||
|
"""检查每日限额"""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
# 统计今日互动
|
||||||
|
today_records = self.db.query(InteractionRecord).filter(
|
||||||
|
and_(
|
||||||
|
InteractionRecord.virtual_user_id == user.id,
|
||||||
|
func.date(InteractionRecord.execution_time) == today,
|
||||||
|
InteractionRecord.status == InteractionStatus.SUCCESS
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
today_comments = sum(1 for r in today_records if r.interaction_type == InteractionType.COMMENT)
|
||||||
|
today_replies = sum(1 for r in today_records if r.interaction_type == InteractionType.REPLY)
|
||||||
|
|
||||||
|
# 检查评论限额
|
||||||
|
if interaction_type == InteractionType.COMMENT:
|
||||||
|
if today_comments >= settings.MAX_COMMENTS_PER_USER_PER_DAY:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 检查回复限额
|
||||||
|
if interaction_type == InteractionType.REPLY:
|
||||||
|
if today_replies >= settings.MAX_REPLIES_PER_USER_PER_DAY:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _random_interaction_type(self) -> InteractionType:
|
||||||
|
"""随机选择互动类型"""
|
||||||
|
rand = random.random()
|
||||||
|
|
||||||
|
# 根据概率决定互动类型
|
||||||
|
if rand < settings.LIKE_PROBABILITY:
|
||||||
|
return InteractionType.LIKE
|
||||||
|
elif rand < settings.LIKE_PROBABILITY + settings.FAVORITE_PROBABILITY:
|
||||||
|
return InteractionType.FAVORITE
|
||||||
|
elif rand < settings.LIKE_PROBABILITY + settings.FAVORITE_PROBABILITY + settings.SHARE_PROBABILITY:
|
||||||
|
return InteractionType.SHARE
|
||||||
|
else:
|
||||||
|
return InteractionType.COMMENT
|
||||||
|
|
||||||
|
async def _select_news(self, user: VirtualUser) -> Optional[str]:
|
||||||
|
"""选择新闻"""
|
||||||
|
# 优先选择未互动过的新闻
|
||||||
|
cached_news = self.db.query(NewsCache).order_by(
|
||||||
|
NewsCache.created_at.desc()
|
||||||
|
).limit(50).all()
|
||||||
|
|
||||||
|
if not cached_news:
|
||||||
|
# 从 API 获取
|
||||||
|
news_list = await huihui_api_service.get_news_list(page=1, page_size=20)
|
||||||
|
if news_list:
|
||||||
|
for news_data in news_list:
|
||||||
|
self._cache_news(news_data)
|
||||||
|
cached_news = self.db.query(NewsCache).order_by(
|
||||||
|
NewsCache.created_at.desc()
|
||||||
|
).limit(50).all()
|
||||||
|
|
||||||
|
if not cached_news:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 随机选择一篇
|
||||||
|
return random.choice(cached_news).news_id
|
||||||
|
|
||||||
|
def _cache_news(self, news_data: Dict[str, Any]) -> Optional[NewsCache]:
|
||||||
|
"""缓存新闻"""
|
||||||
|
news = NewsCache(
|
||||||
|
news_id=str(news_data.get("id")),
|
||||||
|
title=news_data.get("title", ""),
|
||||||
|
summary=news_data.get("summary", ""),
|
||||||
|
content=news_data.get("content", ""),
|
||||||
|
source=news_data.get("source", ""),
|
||||||
|
author=news_data.get("author", ""),
|
||||||
|
category=news_data.get("category", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(news)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(news)
|
||||||
|
return news
|
||||||
|
|
||||||
|
def _record_token_usage(
|
||||||
|
self,
|
||||||
|
virtual_user_id: int,
|
||||||
|
interaction_id: int,
|
||||||
|
tokens_used: int,
|
||||||
|
ai_model: str,
|
||||||
|
action_type: str,
|
||||||
|
tokens_prompt: int = 0,
|
||||||
|
tokens_completion: int = 0
|
||||||
|
):
|
||||||
|
"""记录 Token 使用"""
|
||||||
|
usage = TokenUsage(
|
||||||
|
virtual_user_id=virtual_user_id,
|
||||||
|
interaction_id=interaction_id,
|
||||||
|
tokens_used=tokens_used,
|
||||||
|
tokens_prompt=tokens_prompt,
|
||||||
|
tokens_completion=tokens_completion,
|
||||||
|
ai_model=ai_model,
|
||||||
|
action_type=action_type
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(usage)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Token usage recorded: {tokens_used} tokens for user {virtual_user_id}")
|
||||||
|
|
||||||
|
|
||||||
|
# 工厂函数
|
||||||
|
def get_interaction_service(db: Session) -> InteractionService:
|
||||||
|
"""获取互动服务实例"""
|
||||||
|
return InteractionService(db)
|
||||||
215
backend/app/services/scheduler_service.py
Normal file
215
backend/app/services/scheduler_service.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
定时任务调度服务
|
||||||
|
基于 APScheduler 实现
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime, time
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.virtual_user import VirtualUser, ActivityLevel, UserStatus
|
||||||
|
from app.models.base import get_db, SessionLocal
|
||||||
|
from app.services.interaction_service import InteractionService
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerService:
|
||||||
|
"""定时任务调度服务类"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.scheduler = AsyncIOScheduler()
|
||||||
|
self.is_running = False
|
||||||
|
self._current_job = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""启动调度器"""
|
||||||
|
if not self.is_running:
|
||||||
|
self.scheduler.start()
|
||||||
|
self.is_running = True
|
||||||
|
logger.info("Scheduler started")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""停止调度器"""
|
||||||
|
if self.is_running:
|
||||||
|
self.scheduler.shutdown()
|
||||||
|
self.is_running = False
|
||||||
|
logger.info("Scheduler stopped")
|
||||||
|
|
||||||
|
def add_interaction_task(self):
|
||||||
|
"""添加互动任务"""
|
||||||
|
# 在活动时间段内,每隔随机时间执行一次互动
|
||||||
|
# 由于 APScheduler 不支持随机间隔,我们使用固定间隔但通过概率控制执行
|
||||||
|
|
||||||
|
# 每 5 分钟检查一次
|
||||||
|
trigger = IntervalTrigger(minutes=5)
|
||||||
|
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._execute_random_interaction,
|
||||||
|
trigger=trigger,
|
||||||
|
id="random_interaction",
|
||||||
|
name="Random Interaction Task",
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Interaction task added")
|
||||||
|
|
||||||
|
def remove_interaction_task(self):
|
||||||
|
"""移除互动任务"""
|
||||||
|
try:
|
||||||
|
self.scheduler.remove_job("random_interaction")
|
||||||
|
logger.info("Interaction task removed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Remove interaction task error: {e}")
|
||||||
|
|
||||||
|
async def _execute_random_interaction(self):
|
||||||
|
"""执行随机互动任务"""
|
||||||
|
# 检查是否在活动时间段内
|
||||||
|
now = datetime.now()
|
||||||
|
current_hour = now.hour
|
||||||
|
|
||||||
|
if current_hour < settings.TASK_START_HOUR or current_hour > settings.TASK_END_HOUR:
|
||||||
|
logger.debug(f"Outside activity hours: {current_hour}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 随机决定是否执行(通过随机间隔模拟)
|
||||||
|
if random.random() > 0.5: # 50% 概率执行
|
||||||
|
logger.debug("Skip this round")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Executing random interaction task")
|
||||||
|
|
||||||
|
# 获取数据库会话
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# 获取所有活跃的虚拟用户
|
||||||
|
users = db.query(VirtualUser).filter(
|
||||||
|
VirtualUser.status == UserStatus.ACTIVE,
|
||||||
|
VirtualUser.is_logged_in == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
logger.debug("No active logged-in users")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 随机选择一个用户
|
||||||
|
user = random.choice(users)
|
||||||
|
|
||||||
|
# 检查用户活跃度
|
||||||
|
if not self._should_user_interact(user):
|
||||||
|
logger.debug(f"User {user.id} should not interact now")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 执行互动
|
||||||
|
interaction_service = InteractionService(db)
|
||||||
|
await interaction_service.execute_interaction(virtual_user_id=user.id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Execute random interaction error: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def _should_user_interact(self, user: VirtualUser) -> bool:
|
||||||
|
"""根据活跃度判断用户是否应该互动"""
|
||||||
|
# 根据活跃度决定互动概率
|
||||||
|
if user.activity_level == ActivityLevel.HIGH:
|
||||||
|
# 高活跃度:80% 概率
|
||||||
|
return random.random() < 0.8
|
||||||
|
elif user.activity_level == ActivityLevel.MEDIUM:
|
||||||
|
# 中活跃度:50% 概率
|
||||||
|
return random.random() < 0.5
|
||||||
|
else:
|
||||||
|
# 低活跃度:30% 概率
|
||||||
|
return random.random() < 0.3
|
||||||
|
|
||||||
|
def add_login_task(self, hour: int = 8, minute: int = 0):
|
||||||
|
"""添加每日登录任务"""
|
||||||
|
trigger = CronTrigger(hour=hour, minute=minute)
|
||||||
|
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._auto_login_users,
|
||||||
|
trigger=trigger,
|
||||||
|
id="daily_login",
|
||||||
|
name="Daily Auto Login",
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Daily login task added at {hour:02d}:{minute:02d}")
|
||||||
|
|
||||||
|
async def _auto_login_users(self):
|
||||||
|
"""自动登录所有活跃用户"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from app.services.huihui_api_service import huihui_api_service
|
||||||
|
|
||||||
|
users = db.query(VirtualUser).filter(
|
||||||
|
VirtualUser.status == UserStatus.ACTIVE
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
# 调用登录接口
|
||||||
|
result = await huihui_api_service.login(user.username, user.password)
|
||||||
|
|
||||||
|
if result and result.get("token"):
|
||||||
|
user.is_logged_in = True
|
||||||
|
user.session_token = result["token"]
|
||||||
|
# TODO: 设置 token 过期时间
|
||||||
|
|
||||||
|
logger.info(f"Auto login success: {user.username}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Auto login failed: {user.username}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Auto login error for {user.username}: {e}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Auto login task error: {e}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def reset_daily_counters(self, hour: int = 0, minute: int = 1):
|
||||||
|
"""添加每日计数器重置任务"""
|
||||||
|
trigger = CronTrigger(hour=hour, minute=minute)
|
||||||
|
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._reset_daily_counters,
|
||||||
|
trigger=trigger,
|
||||||
|
id="reset_daily_counters",
|
||||||
|
name="Reset Daily Counters",
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Daily reset task added at {hour:02d}:{minute:02d}")
|
||||||
|
|
||||||
|
def _reset_daily_counters(self):
|
||||||
|
"""重置每日计数器"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# 重置所有用户的今日计数
|
||||||
|
db.query(VirtualUser).update({
|
||||||
|
VirtualUser.today_comments: 0,
|
||||||
|
VirtualUser.today_replies: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info("Daily counters reset")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Reset daily counters error: {e}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# 创建全局服务实例
|
||||||
|
scheduler_service = SchedulerService()
|
||||||
173
backend/app/services/token_service.py
Normal file
173
backend/app/services/token_service.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
Token 使用统计服务
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, and_, extract
|
||||||
|
|
||||||
|
from app.models.token_usage import TokenUsage
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenService:
|
||||||
|
"""Token 统计服务类"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_today_usage(self) -> int:
|
||||||
|
"""获取今日 Token 使用量"""
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
result = self.db.query(func.sum(TokenUsage.tokens_used)).filter(
|
||||||
|
func.date(TokenUsage.usage_date) == today
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
return result or 0
|
||||||
|
|
||||||
|
def get_yesterday_usage(self) -> int:
|
||||||
|
"""获取昨日 Token 使用量"""
|
||||||
|
yesterday = date.today() - timedelta(days=1)
|
||||||
|
|
||||||
|
result = self.db.query(func.sum(TokenUsage.tokens_used)).filter(
|
||||||
|
func.date(TokenUsage.usage_date) == yesterday
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
return result or 0
|
||||||
|
|
||||||
|
def get_month_usage(self, year: Optional[int] = None, month: Optional[int] = None) -> int:
|
||||||
|
"""获取当月 Token 使用量"""
|
||||||
|
if not year or not month:
|
||||||
|
now = datetime.now()
|
||||||
|
year = now.year
|
||||||
|
month = now.month
|
||||||
|
|
||||||
|
result = self.db.query(func.sum(TokenUsage.tokens_used)).filter(
|
||||||
|
and_(
|
||||||
|
extract('year', TokenUsage.usage_date) == year,
|
||||||
|
extract('month', TokenUsage.usage_date) == month
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
return result or 0
|
||||||
|
|
||||||
|
def get_remaining_tokens(self) -> int:
|
||||||
|
"""获取今日剩余 Token"""
|
||||||
|
today_used = self.get_today_usage()
|
||||||
|
remaining = settings.MAX_TOKENS_PER_DAY - today_used
|
||||||
|
return max(0, remaining)
|
||||||
|
|
||||||
|
def get_daily_usages(self, days: int = 30) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取每日 Token 使用(用于图表)
|
||||||
|
:param days: 天数
|
||||||
|
:return: 每日使用列表
|
||||||
|
"""
|
||||||
|
end_date = date.today()
|
||||||
|
start_date = end_date - timedelta(days=days - 1)
|
||||||
|
|
||||||
|
results = self.db.query(
|
||||||
|
func.date(TokenUsage.usage_date).label('usage_date'),
|
||||||
|
func.sum(TokenUsage.tokens_used).label('total_tokens')
|
||||||
|
).filter(
|
||||||
|
and_(
|
||||||
|
func.date(TokenUsage.usage_date) >= start_date,
|
||||||
|
func.date(TokenUsage.usage_date) <= end_date
|
||||||
|
)
|
||||||
|
).group_by(
|
||||||
|
func.date(TokenUsage.usage_date)
|
||||||
|
).order_by(
|
||||||
|
func.date(TokenUsage.usage_date)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 转换为字典列表
|
||||||
|
usage_dict = {str(row.usage_date): row.total_tokens for row in results}
|
||||||
|
|
||||||
|
# 填充缺失的日期
|
||||||
|
daily_usages = []
|
||||||
|
current_date = start_date
|
||||||
|
while current_date <= end_date:
|
||||||
|
date_str = str(current_date)
|
||||||
|
tokens = usage_dict.get(date_str, 0)
|
||||||
|
daily_usages.append({
|
||||||
|
"date": date_str,
|
||||||
|
"tokens": tokens
|
||||||
|
})
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
return daily_usages
|
||||||
|
|
||||||
|
def get_monthly_usages(self, months: int = 12) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取每月 Token 使用(用于图表)
|
||||||
|
:param months: 月数
|
||||||
|
:return: 每月使用列表
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for i in range(months):
|
||||||
|
# 计算月份
|
||||||
|
month_offset = months - 1 - i
|
||||||
|
target_date = now - timedelta(days=30 * month_offset)
|
||||||
|
year = target_date.year
|
||||||
|
month = target_date.month
|
||||||
|
|
||||||
|
# 查询该月的使用量
|
||||||
|
usage = self.get_month_usage(year, month)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"month": f"{year}-{month:02d}",
|
||||||
|
"tokens": usage
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_user_token_usage(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
days: int = 30
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取指定用户的 Token 使用
|
||||||
|
:param user_id: 用户 ID
|
||||||
|
:param days: 天数
|
||||||
|
:return: 每日使用列表
|
||||||
|
"""
|
||||||
|
end_date = date.today()
|
||||||
|
start_date = end_date - timedelta(days=days - 1)
|
||||||
|
|
||||||
|
results = self.db.query(
|
||||||
|
func.date(TokenUsage.usage_date).label('usage_date'),
|
||||||
|
func.sum(TokenUsage.tokens_used).label('total_tokens')
|
||||||
|
).filter(
|
||||||
|
and_(
|
||||||
|
TokenUsage.virtual_user_id == user_id,
|
||||||
|
func.date(TokenUsage.usage_date) >= start_date,
|
||||||
|
func.date(TokenUsage.usage_date) <= end_date
|
||||||
|
)
|
||||||
|
).group_by(
|
||||||
|
func.date(TokenUsage.usage_date)
|
||||||
|
).order_by(
|
||||||
|
func.date(TokenUsage.usage_date)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"date": str(row.usage_date), "tokens": row.total_tokens}
|
||||||
|
for row in results
|
||||||
|
]
|
||||||
|
|
||||||
|
def check_token_limit_exceeded(self) -> bool:
|
||||||
|
"""检查是否超出 Token 限额"""
|
||||||
|
today_used = self.get_today_usage()
|
||||||
|
return today_used >= settings.MAX_TOKENS_PER_DAY
|
||||||
|
|
||||||
|
|
||||||
|
# 工厂函数
|
||||||
|
def get_token_service(db: Session) -> TokenService:
|
||||||
|
"""获取 Token 服务实例"""
|
||||||
|
return TokenService(db)
|
||||||
361
backend/app/services/virtual_user_service.py
Normal file
361
backend/app/services/virtual_user_service.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
"""
|
||||||
|
虚拟用户管理服务
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, func, Date
|
||||||
|
|
||||||
|
from app.models.virtual_user import VirtualUser, VirtualUserPersona, ActivityLevel, UserStatus
|
||||||
|
from app.models.interaction import InteractionRecord, InteractionType
|
||||||
|
from app.services.ai_service import ai_service
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualUserService:
|
||||||
|
"""虚拟用户服务类"""
|
||||||
|
|
||||||
|
# 预设写作风格库
|
||||||
|
WRITING_STYLES = [
|
||||||
|
"幽默风趣",
|
||||||
|
"严肃理性",
|
||||||
|
"文艺清新",
|
||||||
|
"吐槽犀利",
|
||||||
|
"感性温暖",
|
||||||
|
"客观中立",
|
||||||
|
"激情澎湃",
|
||||||
|
"冷静分析",
|
||||||
|
"活泼可爱",
|
||||||
|
"深沉内敛"
|
||||||
|
]
|
||||||
|
|
||||||
|
# 昵称前缀和后缀
|
||||||
|
NICKNAME_PREFIXES = ["清风", "星辰", "云端", "晨曦", "暮色", "流年", "初心", "远方"]
|
||||||
|
NICKNAME_SUFFIXES = ["行者", "旅人", "追梦", "时光", "记忆", "印象", "故事", "传奇"]
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_user_by_id(self, user_id: int) -> Optional[VirtualUser]:
|
||||||
|
"""根据 ID 获取用户"""
|
||||||
|
return self.db.query(VirtualUser).filter(VirtualUser.id == user_id).first()
|
||||||
|
|
||||||
|
def get_user_by_username(self, username: str) -> Optional[VirtualUser]:
|
||||||
|
"""根据用户名获取用户"""
|
||||||
|
return self.db.query(VirtualUser).filter(VirtualUser.username == username).first()
|
||||||
|
|
||||||
|
def get_users(
|
||||||
|
self,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
status: Optional[UserStatus] = None,
|
||||||
|
search: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取用户列表
|
||||||
|
:param page: 页码
|
||||||
|
:param page_size: 每页数量
|
||||||
|
:param status: 状态筛选
|
||||||
|
:param search: 搜索关键词
|
||||||
|
:return: 用户列表和总数
|
||||||
|
"""
|
||||||
|
query = self.db.query(VirtualUser)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(VirtualUser.status == status)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
VirtualUser.nickname.like(f"%{search}%"),
|
||||||
|
VirtualUser.username.like(f"%{search}%")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
users = query.order_by(VirtualUser.created_at.desc()).offset(
|
||||||
|
(page - 1) * page_size
|
||||||
|
).limit(page_size).all()
|
||||||
|
|
||||||
|
return {"total": total, "items": users}
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
nickname: str,
|
||||||
|
writing_style: Optional[str] = None,
|
||||||
|
activity_level: ActivityLevel = ActivityLevel.MEDIUM,
|
||||||
|
avatar_url: Optional[str] = None,
|
||||||
|
persona_description: Optional[str] = None
|
||||||
|
) -> Optional[VirtualUser]:
|
||||||
|
"""
|
||||||
|
创建虚拟用户
|
||||||
|
:param username: 用户名
|
||||||
|
:param password: 密码
|
||||||
|
:param nickname: 昵称
|
||||||
|
:param writing_style: 写作风格
|
||||||
|
:param activity_level: 活跃度
|
||||||
|
:param avatar_url: 头像 URL
|
||||||
|
:param persona_description: 人格描述
|
||||||
|
:return: 创建的用户
|
||||||
|
"""
|
||||||
|
# 检查用户名是否已存在
|
||||||
|
existing = self.get_user_by_username(username)
|
||||||
|
if existing:
|
||||||
|
logger.error(f"Username already exists: {username}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = VirtualUser(
|
||||||
|
username=username,
|
||||||
|
password=password, # TODO: 加密存储
|
||||||
|
nickname=nickname,
|
||||||
|
writing_style=writing_style or self._random_writing_style(),
|
||||||
|
activity_level=activity_level,
|
||||||
|
avatar_url=avatar_url or self._generate_avatar_url(),
|
||||||
|
persona_description=persona_description
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(user)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user)
|
||||||
|
|
||||||
|
logger.info(f"Virtual user created: {username}")
|
||||||
|
return user
|
||||||
|
|
||||||
|
def generate_users(
|
||||||
|
self,
|
||||||
|
count: int,
|
||||||
|
writing_styles: Optional[List[str]] = None,
|
||||||
|
activity_levels: Optional[List[ActivityLevel]] = None,
|
||||||
|
generate_persona: bool = True
|
||||||
|
) -> List[VirtualUser]:
|
||||||
|
"""
|
||||||
|
批量生成虚拟用户
|
||||||
|
:param count: 生成数量
|
||||||
|
:param writing_styles: 写作风格列表
|
||||||
|
:param activity_levels: 活跃度级别列表
|
||||||
|
:param generate_persona: 是否生成 AI 人格描述
|
||||||
|
:return: 生成的用户列表
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
styles = writing_styles or self.WRITING_STYLES
|
||||||
|
levels = activity_levels or [ActivityLevel.LOW, ActivityLevel.MEDIUM, ActivityLevel.HIGH]
|
||||||
|
|
||||||
|
created_users = []
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
# 生成唯一用户名
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
username = f"user_{timestamp}_{i}"
|
||||||
|
|
||||||
|
# 随机生成昵称
|
||||||
|
prefix = random.choice(self.NICKNAME_PREFIXES)
|
||||||
|
suffix = random.choice(self.NICKNAME_SUFFIXES)
|
||||||
|
nickname = f"{prefix}{suffix}{random.randint(100, 999)}"
|
||||||
|
|
||||||
|
# 随机密码
|
||||||
|
password = f"pwd_{random.randint(100000, 999999)}"
|
||||||
|
|
||||||
|
# 随机写作风格
|
||||||
|
writing_style = random.choice(styles)
|
||||||
|
|
||||||
|
# 随机活跃度
|
||||||
|
activity_level = random.choice(levels)
|
||||||
|
|
||||||
|
# 生成头像
|
||||||
|
avatar_url = self._generate_avatar_url()
|
||||||
|
|
||||||
|
# AI 生成人格描述
|
||||||
|
persona_description = None
|
||||||
|
if generate_persona:
|
||||||
|
persona_description = self._generate_persona_description(
|
||||||
|
writing_style,
|
||||||
|
activity_level
|
||||||
|
)
|
||||||
|
|
||||||
|
user = self.create_user(
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
nickname=nickname,
|
||||||
|
writing_style=writing_style,
|
||||||
|
activity_level=activity_level,
|
||||||
|
avatar_url=avatar_url,
|
||||||
|
persona_description=persona_description
|
||||||
|
)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
created_users.append(user)
|
||||||
|
|
||||||
|
logger.info(f"Generated {len(created_users)} virtual users")
|
||||||
|
return created_users
|
||||||
|
|
||||||
|
def _generate_persona_description(
|
||||||
|
self,
|
||||||
|
writing_style: str,
|
||||||
|
activity_level: ActivityLevel
|
||||||
|
) -> str:
|
||||||
|
"""AI 生成人格描述"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
prompt = f"""请为一位虚拟用户生成人格描述,要求:
|
||||||
|
- 写作风格:{writing_style}
|
||||||
|
- 活跃度:{activity_level.value}
|
||||||
|
|
||||||
|
请用 50-100 字描述这个人的性格特点、兴趣爱好、说话方式等。直接输出描述内容。"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 同步调用异步方法
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
result = loop.run_until_complete(ai_service._call_ai_api(prompt))
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
if result and result.get("content"):
|
||||||
|
return result["content"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Generate persona description error: {e}")
|
||||||
|
|
||||||
|
return f"这是一位{writing_style}的虚拟用户,活跃度{activity_level.value}。"
|
||||||
|
|
||||||
|
def _random_writing_style(self) -> str:
|
||||||
|
"""随机选择写作风格"""
|
||||||
|
import random
|
||||||
|
return random.choice(self.WRITING_STYLES)
|
||||||
|
|
||||||
|
def _generate_avatar_url(self) -> str:
|
||||||
|
"""生成随机头像 URL(使用第三方头像 API)"""
|
||||||
|
import random
|
||||||
|
# 使用 DiceBear 头像 API
|
||||||
|
seed = f"avatar_{datetime.now().timestamp()}_{random.randint(1000, 9999)}"
|
||||||
|
return f"https://api.dicebear.com/7.x/avataaars/svg?seed={seed}"
|
||||||
|
|
||||||
|
def update_user(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
**kwargs
|
||||||
|
) -> Optional[VirtualUser]:
|
||||||
|
"""更新用户信息"""
|
||||||
|
user = self.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(user, key) and value is not None:
|
||||||
|
setattr(user, key, value)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def delete_user(self, user_id: int) -> bool:
|
||||||
|
"""删除用户"""
|
||||||
|
user = self.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.db.delete(user)
|
||||||
|
self.db.commit()
|
||||||
|
logger.info(f"Virtual user deleted: {user_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def import_users_from_excel(
|
||||||
|
self,
|
||||||
|
users_data: List[Dict[str, Any]],
|
||||||
|
generate_persona: bool = True
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
从 Excel 导入虚拟用户
|
||||||
|
:param users_data: 用户数据列表
|
||||||
|
:param generate_persona: 是否生成 AI 人格描述
|
||||||
|
:return: 导入结果
|
||||||
|
"""
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
created_users = []
|
||||||
|
|
||||||
|
for user_data in users_data:
|
||||||
|
try:
|
||||||
|
username = user_data.get("username")
|
||||||
|
password = user_data.get("password")
|
||||||
|
nickname = user_data.get("nickname", "")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
logger.warning(f"Missing username or password: {user_data}")
|
||||||
|
failed_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 如果昵称为空,生成一个
|
||||||
|
if not nickname:
|
||||||
|
nickname = f"用户{username}"
|
||||||
|
|
||||||
|
writing_style = user_data.get("writing_style")
|
||||||
|
activity_level_str = user_data.get("activity_level", "medium")
|
||||||
|
|
||||||
|
# 转换活跃度枚举
|
||||||
|
try:
|
||||||
|
activity_level = ActivityLevel(activity_level_str.lower())
|
||||||
|
except ValueError:
|
||||||
|
activity_level = ActivityLevel.MEDIUM
|
||||||
|
|
||||||
|
user = self.create_user(
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
nickname=nickname,
|
||||||
|
writing_style=writing_style,
|
||||||
|
activity_level=activity_level
|
||||||
|
)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
success_count += 1
|
||||||
|
created_users.append(user)
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Import user error: {e}")
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success_count": success_count,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"created_users": created_users
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_user_stats(self, user_id: int) -> Dict[str, Any]:
|
||||||
|
"""获取用户统计信息"""
|
||||||
|
user = self.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
# 统计今日互动
|
||||||
|
today_interactions = self.db.query(InteractionRecord).filter(
|
||||||
|
and_(
|
||||||
|
InteractionRecord.virtual_user_id == user_id,
|
||||||
|
func.date(InteractionRecord.execution_time) == today
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
today_comments = sum(1 for i in today_interactions if i.interaction_type == InteractionType.COMMENT)
|
||||||
|
today_replies = sum(1 for i in today_interactions if i.interaction_type == InteractionType.REPLY)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"nickname": user.nickname,
|
||||||
|
"total_interactions": user.total_interactions,
|
||||||
|
"today_comments": today_comments,
|
||||||
|
"today_replies": today_replies,
|
||||||
|
"last_interaction_time": user.last_interaction_time
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 工厂函数
|
||||||
|
def get_virtual_user_service(db: Session) -> VirtualUserService:
|
||||||
|
"""获取虚拟用户服务实例"""
|
||||||
|
return VirtualUserService(db)
|
||||||
35
backend/requirements.txt
Normal file
35
backend/requirements.txt
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Web Framework
|
||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy==2.0.25
|
||||||
|
alembic==1.13.1
|
||||||
|
pymysql==1.1.0
|
||||||
|
|
||||||
|
# AI Models
|
||||||
|
openai==1.10.0
|
||||||
|
zhipuai==2.0.1
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
httpx==0.26.0
|
||||||
|
apscheduler==3.10.4
|
||||||
|
|
||||||
|
# Excel Support
|
||||||
|
openpyxl==3.1.2
|
||||||
|
pandas==2.1.4
|
||||||
|
|
||||||
|
# Security
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
loguru==0.7.2
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest==7.4.4
|
||||||
|
pytest-asyncio==0.23.3
|
||||||
0
data/logs/.gitkeep
Normal file
0
data/logs/.gitkeep
Normal file
0
data/uploads/.gitkeep
Normal file
0
data/uploads/.gitkeep
Normal file
15
docker/mysql/init.sql
Normal file
15
docker/mysql/init.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- 初始化数据库表结构(如果使用 SQLAlchemy 自动创建,此文件可选)
|
||||||
|
|
||||||
|
-- 创建扩展
|
||||||
|
CREATE DATABASE IF NOT EXISTS huihui_ai_bot
|
||||||
|
DEFAULT CHARACTER SET utf8mb4
|
||||||
|
DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE huihui_ai_bot;
|
||||||
|
|
||||||
|
-- 插入初始系统配置数据
|
||||||
|
INSERT INTO system_configs (config_key, config_value, config_type, description, is_active) VALUES
|
||||||
|
('schedule_config', '{"task_start_hour": 9, "task_end_hour": 22, "task_interval_min": 10, "task_interval_max": 30}', 'schedule', '定时任务配置', 1),
|
||||||
|
('limit_config', '{"max_tokens_per_day": 10000, "max_comments_per_user_per_day": 20, "max_replies_per_user_per_day": 10}', 'limit', '限额配置', 1),
|
||||||
|
('probability_config', '{"like_probability": 0.8, "favorite_probability": 0.5, "share_probability": 0.3}', 'probability', '概率配置', 1)
|
||||||
|
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value);
|
||||||
41
docker/nginx/nginx.conf
Normal file
41
docker/nginx/nginx.conf
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 代理到后端服务
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket 支持(如果需要)
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# 超时设置
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gzip 压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||||
|
}
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>会会虚拟用户 AI 互动系统</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "huihui-ai-bot-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.2.5",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"element-plus": "^2.5.0",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"echarts": "^5.4.3",
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"dayjs": "^1.11.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"vite": "^5.0.10",
|
||||||
|
"sass": "^1.69.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
frontend/src/App.vue
Normal file
22
frontend/src/App.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
56
frontend/src/api/index.js
Normal file
56
frontend/src/api/index.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
// 仪表盘 API
|
||||||
|
export const dashboardApi = {
|
||||||
|
getStats: () => request.get('/dashboard'),
|
||||||
|
getTokenStats: () => request.get('/dashboard/token/stats'),
|
||||||
|
getDailyUsage: (days = 30) => request.get(`/dashboard/token/daily?days=${days}`),
|
||||||
|
getMonthlyUsage: (months = 12) => request.get(`/dashboard/token/monthly?months=${months}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 虚拟用户 API
|
||||||
|
export const virtualUserApi = {
|
||||||
|
getList: (params) => request.get('/virtual-users', { params }),
|
||||||
|
getById: (id) => request.get(`/virtual-users/${id}`),
|
||||||
|
create: (data) => request.post('/virtual-users', data),
|
||||||
|
generate: (data) => request.post('/virtual-users/generate', data),
|
||||||
|
update: (id, data) => request.put(`/virtual-users/${id}`, data),
|
||||||
|
delete: (id) => request.delete(`/virtual-users/${id}`),
|
||||||
|
import: (file, generatePersona = true) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return request.post('/virtual-users/import', formData, {
|
||||||
|
params: { generate_persona: generatePersona }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getStats: (id) => request.get(`/virtual-users/${id}/stats`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 互动记录 API
|
||||||
|
export const interactionApi = {
|
||||||
|
getList: (params) => request.get('/interactions', { params }),
|
||||||
|
execute: (data) => request.post('/interactions/execute', data),
|
||||||
|
retry: (id) => request.post(`/interactions/retry/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 模型 API
|
||||||
|
export const aiModelApi = {
|
||||||
|
getList: () => request.get('/ai-models'),
|
||||||
|
getById: (id) => request.get(`/ai-models/${id}`),
|
||||||
|
create: (data) => request.post('/ai-models', data),
|
||||||
|
update: (id, data) => request.put(`/ai-models/${id}`, data),
|
||||||
|
delete: (id) => request.delete(`/ai-models/${id}`),
|
||||||
|
test: (data) => request.post('/ai-models/test', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统配置 API
|
||||||
|
export const systemApi = {
|
||||||
|
getSchedule: () => request.get('/system/schedule'),
|
||||||
|
getLimits: () => request.get('/system/limits'),
|
||||||
|
getProbabilities: () => request.get('/system/probabilities'),
|
||||||
|
updateSchedule: (data) => request.put('/system/schedule', data),
|
||||||
|
updateLimits: (data) => request.put('/system/limits', data),
|
||||||
|
startScheduler: () => request.post('/system/scheduler/start'),
|
||||||
|
stopScheduler: () => request.post('/system/scheduler/stop'),
|
||||||
|
getSchedulerStatus: () => request.get('/system/scheduler/status')
|
||||||
|
}
|
||||||
60
frontend/src/api/request.js
Normal file
60
frontend/src/api/request.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
timeout: 30000
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
request.interceptors.request.use(
|
||||||
|
config => {
|
||||||
|
// TODO: 添加 token
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
request.interceptors.response.use(
|
||||||
|
response => {
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
let message = '请求失败'
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
switch (error.response.status) {
|
||||||
|
case 400:
|
||||||
|
message = error.response.data.detail || '请求参数错误'
|
||||||
|
break
|
||||||
|
case 401:
|
||||||
|
message = '未授权,请登录'
|
||||||
|
break
|
||||||
|
case 403:
|
||||||
|
message = '拒绝访问'
|
||||||
|
break
|
||||||
|
case 404:
|
||||||
|
message = '请求地址出错'
|
||||||
|
break
|
||||||
|
case 500:
|
||||||
|
message = '服务器内部错误'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
message = error.response.data.detail || message
|
||||||
|
}
|
||||||
|
} else if (error.message.includes('timeout')) {
|
||||||
|
message = '请求超时'
|
||||||
|
} else if (error.message.includes('Network')) {
|
||||||
|
message = '网络连接失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.error(message)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default request
|
||||||
25
frontend/src/main.js
Normal file
25
frontend/src/main.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
// 注册 Element Plus 图标
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, {
|
||||||
|
locale: zhCn,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
48
frontend/src/router/index.js
Normal file
48
frontend/src/router/index.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('@/views/Dashboard.vue'),
|
||||||
|
meta: { title: '控制台' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/virtual-users',
|
||||||
|
name: 'VirtualUsers',
|
||||||
|
component: () => import('@/views/VirtualUsers.vue'),
|
||||||
|
meta: { title: '虚拟用户管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/interactions',
|
||||||
|
name: 'Interactions',
|
||||||
|
component: () => import('@/views/Interactions.vue'),
|
||||||
|
meta: { title: '互动记录' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ai-models',
|
||||||
|
name: 'AIModels',
|
||||||
|
component: () => import('@/views/AIModels.vue'),
|
||||||
|
meta: { title: 'AI 模型配置' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: () => import('@/views/Settings.vue'),
|
||||||
|
meta: { title: '系统设置' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
if (to.meta.title) {
|
||||||
|
document.title = `${to.meta.title} - 会会虚拟用户 AI 互动系统`
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
275
frontend/src/views/AIModels.vue
Normal file
275
frontend/src/views/AIModels.vue
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-models">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>AI 模型配置</span>
|
||||||
|
<el-button type="primary" @click="showAddDialog = true">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加模型
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table :data="modelList" v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="display_name" label="名称" width="150" />
|
||||||
|
<el-table-column prop="model_name" label="模型" width="150" />
|
||||||
|
<el-table-column prop="provider" label="提供商" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small">{{ getProviderLabel(row.provider) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="temperature" label="温度" width="100" />
|
||||||
|
<el-table-column prop="max_tokens" label="最大 Token" width="100" />
|
||||||
|
<el-table-column prop="is_default" label="默认" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="row.is_default ? 'success' : 'info'">
|
||||||
|
{{ row.is_default ? '是' : '否' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="is_active" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="row.is_active ? 'success' : 'info'">
|
||||||
|
{{ row.is_active ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" fixed="right" width="250">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="handleTest(row)">测试</el-button>
|
||||||
|
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 添加/编辑对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showModelDialog"
|
||||||
|
:title="isEdit ? '编辑模型' : '添加模型'"
|
||||||
|
width="600px"
|
||||||
|
>
|
||||||
|
<el-form :model="modelForm" label-width="120px">
|
||||||
|
<el-form-item label="显示名称">
|
||||||
|
<el-input v-model="modelForm.display_name" placeholder="如:GPT-3.5" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模型名称">
|
||||||
|
<el-input v-model="modelForm.model_name" placeholder="如:gpt-3.5-turbo" :disabled="isEdit" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="提供商">
|
||||||
|
<el-select v-model="modelForm.provider" placeholder="请选择" :disabled="isEdit">
|
||||||
|
<el-option label="OpenAI" value="openai" />
|
||||||
|
<el-option label="智谱 AI" value="zhipu" />
|
||||||
|
<el-option label="百度文心" value="baidu" />
|
||||||
|
<el-option label="阿里通义" value="aliyun" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="API 地址">
|
||||||
|
<el-input v-model="modelForm.api_url" placeholder="https://api.openai.com/v1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="API Key">
|
||||||
|
<el-input v-model="modelForm.api_key" type="password" show-password placeholder="sk-..." />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="温度">
|
||||||
|
<el-slider v-model="modelForm.temperature" :min="0" :max="1" :step="0.1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最大 Token 数">
|
||||||
|
<el-input-number v-model="modelForm.max_tokens" :min="1" :max="4096" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设为默认">
|
||||||
|
<el-switch v-model="modelForm.is_default" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="modelForm.is_active" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showModelDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit" :loading="saving">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 测试对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showTestDialog"
|
||||||
|
title="测试模型"
|
||||||
|
width="500px"
|
||||||
|
>
|
||||||
|
<el-form>
|
||||||
|
<el-form-item label="测试提示词">
|
||||||
|
<el-input
|
||||||
|
v-model="testPrompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="请输入测试内容,如:请写一条关于春天的评论"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showTestDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleRunTest" :loading="testing">测试</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { aiModelApi } from '@/api'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const modelList = ref([])
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const showModelDialog = ref(false)
|
||||||
|
const showTestDialog = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const currentModelId = ref(null)
|
||||||
|
const testPrompt = ref('')
|
||||||
|
|
||||||
|
const modelForm = ref({
|
||||||
|
display_name: '',
|
||||||
|
model_name: '',
|
||||||
|
provider: '',
|
||||||
|
api_url: '',
|
||||||
|
api_key: '',
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 1000,
|
||||||
|
is_default: false,
|
||||||
|
is_active: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const testData = ref({})
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await aiModelApi.getList()
|
||||||
|
modelList.value = res || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load AI models error:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加/编辑
|
||||||
|
const handleAdd = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
modelForm.value = {
|
||||||
|
display_name: '',
|
||||||
|
model_name: '',
|
||||||
|
provider: '',
|
||||||
|
api_url: '',
|
||||||
|
api_key: '',
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 1000,
|
||||||
|
is_default: false,
|
||||||
|
is_active: true
|
||||||
|
}
|
||||||
|
showModelDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row) => {
|
||||||
|
isEdit.value = true
|
||||||
|
currentModelId.value = row.id
|
||||||
|
modelForm.value = { ...row }
|
||||||
|
showModelDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await aiModelApi.update(currentModelId.value, modelForm.value)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await aiModelApi.create(modelForm.value)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
showModelDialog.value = false
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save model error:', error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = (row) => {
|
||||||
|
ElMessageBox.confirm('确定要删除此模型配置吗?', '警告', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
await aiModelApi.delete(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete model error:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试
|
||||||
|
const handleTest = (row) => {
|
||||||
|
currentModelId.value = row.id
|
||||||
|
testPrompt.value = '请写一条简短的评论'
|
||||||
|
showTestDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRunTest = async () => {
|
||||||
|
testing.value = true
|
||||||
|
try {
|
||||||
|
const res = await aiModelApi.test({
|
||||||
|
model_id: currentModelId.value,
|
||||||
|
test_prompt: testPrompt.value
|
||||||
|
})
|
||||||
|
|
||||||
|
testData.value = res
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
ElMessage.success(`测试成功!消耗 ${res.tokens_used} tokens`)
|
||||||
|
console.log('Test result:', res.content)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(`测试失败:${res.error_message}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test model error:', error)
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProviderLabel = (provider) => {
|
||||||
|
const map = { openai: 'OpenAI', zhipu: '智谱 AI', baidu: '百度文心', aliyun: '阿里通义' }
|
||||||
|
return map[provider] || provider
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-models {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
319
frontend/src/views/Dashboard.vue
Normal file
319
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<el-card class="box-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>核心指标</span>
|
||||||
|
<el-button @click="refreshData" :loading="loading" circle>
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 核心指标卡片 -->
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-statistic title="虚拟用户总数" :value="stats.core_stats?.total_users || 0">
|
||||||
|
<template #suffix>
|
||||||
|
<span style="font-size: 14px; color: #909399; margin-left: 8px;">
|
||||||
|
(启用:{{ stats.core_stats?.active_users || 0 }} /
|
||||||
|
禁用:{{ stats.core_stats?.disabled_users || 0 }})
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-statistic title="今日互动" :value="todayInteractions">
|
||||||
|
<template #suffix>
|
||||||
|
<span style="font-size: 14px; color: #909399; margin-left: 8px;">
|
||||||
|
评论{{ stats.core_stats?.today_comments || 0 }} /
|
||||||
|
回复{{ stats.core_stats?.today_replies || 0 }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-statistic title="今日 Token" :value="stats.core_stats?.today_tokens || 0">
|
||||||
|
<template #suffix>
|
||||||
|
<span style="font-size: 14px; color: #909399; margin-left: 8px;">
|
||||||
|
剩余:{{ stats.core_stats?.remaining_tokens || 0 }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-statistic title="当月 Token" :value="stats.core_stats?.month_tokens || 0" />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Token 消耗图表 -->
|
||||||
|
<el-row :gutter="20" style="margin-top: 20px;">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>近 30 日 Token 消耗</span>
|
||||||
|
</template>
|
||||||
|
<div ref="dailyChartRef" style="height: 300px;"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>近 12 月 Token 消耗</span>
|
||||||
|
</template>
|
||||||
|
<div ref="monthlyChartRef" style="height: 300px;"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 最近互动记录 -->
|
||||||
|
<el-card style="margin-top: 20px;">
|
||||||
|
<template #header>
|
||||||
|
<span>最近互动记录</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table :data="recentInteractions" style="width: 100%">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="virtual_user_id" label="用户 ID" width="100" />
|
||||||
|
<el-table-column prop="interaction_type" label="类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="getTypeTag(row.interaction_type)">
|
||||||
|
{{ getTypeLabel(row.interaction_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="getStatusTag(row.status)">
|
||||||
|
{{ getStatusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="execution_time" label="执行时间">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.execution_time) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import { dashboardApi } from '@/api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const stats = ref({})
|
||||||
|
const dailyChartRef = ref(null)
|
||||||
|
const monthlyChartRef = ref(null)
|
||||||
|
let dailyChart = null
|
||||||
|
let monthlyChart = null
|
||||||
|
|
||||||
|
const todayInteractions = computed(() => {
|
||||||
|
const s = stats.value.core_stats || {}
|
||||||
|
return (s.today_comments || 0) + (s.today_replies || 0) +
|
||||||
|
(s.today_likes || 0) + (s.today_favorites || 0) + (s.today_shares || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const recentInteractions = computed(() => {
|
||||||
|
return stats.value.recent_interactions || []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await dashboardApi.getStats()
|
||||||
|
stats.value = res
|
||||||
|
|
||||||
|
// 渲染图表
|
||||||
|
renderDailyChart()
|
||||||
|
renderMonthlyChart()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load dashboard data error:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新数据
|
||||||
|
const refreshData = () => {
|
||||||
|
loadData()
|
||||||
|
ElMessage.success('数据已刷新')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染每日 Token 消耗图表
|
||||||
|
const renderDailyChart = () => {
|
||||||
|
if (!dailyChartRef.value) return
|
||||||
|
|
||||||
|
if (dailyChart) {
|
||||||
|
dailyChart.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyChart = echarts.init(dailyChartRef.value)
|
||||||
|
|
||||||
|
const dailyData = stats.value.daily_token_usages || []
|
||||||
|
|
||||||
|
dailyChart.setOption({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '3%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: dailyData.map(item => item.date.substring(5)), // 只显示 MM-DD
|
||||||
|
boundaryGap: false
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value'
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
name: 'Token 消耗',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: dailyData.map(item => item.tokens),
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(64, 158, 255, 0.5)' },
|
||||||
|
{ offset: 1, color: 'rgba(64, 158, 255, 0.05)' }
|
||||||
|
])
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: '#409EFF'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染每月 Token 消耗图表
|
||||||
|
const renderMonthlyChart = () => {
|
||||||
|
if (!monthlyChartRef.value) return
|
||||||
|
|
||||||
|
if (monthlyChart) {
|
||||||
|
monthlyChart.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
monthlyChart = echarts.init(monthlyChartRef.value)
|
||||||
|
|
||||||
|
const monthlyData = stats.value.monthly_token_usages || []
|
||||||
|
|
||||||
|
monthlyChart.setOption({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'shadow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '3%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: monthlyData.map(item => item.month.substring(5)), // 只显示 MM
|
||||||
|
axisTick: {
|
||||||
|
alignWithLabel: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value'
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
name: 'Token 消耗',
|
||||||
|
type: 'bar',
|
||||||
|
barWidth: '60%',
|
||||||
|
data: monthlyData.map(item => item.tokens),
|
||||||
|
itemStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: '#83bff6' },
|
||||||
|
{ offset: 1, color: '#188df0' }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
const getTypeLabel = (type) => {
|
||||||
|
const map = {
|
||||||
|
'comment': '评论',
|
||||||
|
'reply': '回复',
|
||||||
|
'like': '点赞',
|
||||||
|
'favorite': '收藏',
|
||||||
|
'share': '转发'
|
||||||
|
}
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeTag = (type) => {
|
||||||
|
const map = {
|
||||||
|
'comment': 'primary',
|
||||||
|
'reply': 'success',
|
||||||
|
'like': 'warning',
|
||||||
|
'favorite': 'info',
|
||||||
|
'share': 'danger'
|
||||||
|
}
|
||||||
|
return map[type] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusLabel = (status) => {
|
||||||
|
const map = {
|
||||||
|
'pending': '待执行',
|
||||||
|
'success': '成功',
|
||||||
|
'failed': '失败',
|
||||||
|
'retrying': '重试中'
|
||||||
|
}
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusTag = (status) => {
|
||||||
|
const map = {
|
||||||
|
'pending': 'info',
|
||||||
|
'success': 'success',
|
||||||
|
'failed': 'danger',
|
||||||
|
'retrying': 'warning'
|
||||||
|
}
|
||||||
|
return map[status] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time) => {
|
||||||
|
if (!time) return '-'
|
||||||
|
return new Date(time).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
dailyChart?.resize()
|
||||||
|
monthlyChart?.resize()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
109
frontend/src/views/Interactions.vue
Normal file
109
frontend/src/views/Interactions.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="interactions">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>互动记录</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table :data="interactionList" v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="virtual_user_id" label="用户 ID" width="100" />
|
||||||
|
<el-table-column prop="news_title" label="文章标题" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="interaction_type" label="类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="getTypeTag(row.interaction_type)">
|
||||||
|
{{ getTypeLabel(row.interaction_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="getStatusTag(row.status)">
|
||||||
|
{{ getStatusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="tokens_used" label="Token 消耗" width="100" />
|
||||||
|
<el-table-column prop="execution_time" label="执行时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.execution_time) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="pagination.page"
|
||||||
|
v-model:page-size="pagination.pageSize"
|
||||||
|
:total="pagination.total"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
style="margin-top: 20px; justify-content: flex-end;"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { interactionApi } from '@/api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const interactionList = ref([])
|
||||||
|
|
||||||
|
const pagination = ref({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await interactionApi.getList({
|
||||||
|
page: pagination.value.page,
|
||||||
|
page_size: pagination.value.pageSize
|
||||||
|
})
|
||||||
|
interactionList.value = res.items || []
|
||||||
|
pagination.value.total = res.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load interactions error:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeLabel = (type) => {
|
||||||
|
const map = { comment: '评论', reply: '回复', like: '点赞', favorite: '收藏', share: '转发' }
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeTag = (type) => {
|
||||||
|
const map = { comment: 'primary', reply: 'success', like: 'warning', favorite: 'info', share: 'danger' }
|
||||||
|
return map[type] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusLabel = (status) => {
|
||||||
|
const map = { pending: '待执行', success: '成功', failed: '失败', retrying: '重试中' }
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusTag = (status) => {
|
||||||
|
const map = { pending: 'info', success: 'success', failed: 'danger', retrying: 'warning' }
|
||||||
|
return map[status] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time) => {
|
||||||
|
if (!time) return '-'
|
||||||
|
return new Date(time).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.interactions {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
244
frontend/src/views/Settings.vue
Normal file
244
frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<!-- 活动调度设置 -->
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>活动调度设置</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :model="scheduleForm" label-width="140px">
|
||||||
|
<el-form-item label="活动开始时间">
|
||||||
|
<el-time-picker
|
||||||
|
v-model="scheduleForm.task_start_hour"
|
||||||
|
format="HH:mm"
|
||||||
|
value-format="HH"
|
||||||
|
placeholder="选择时间"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="活动结束时间">
|
||||||
|
<el-time-picker
|
||||||
|
v-model="scheduleForm.task_end_hour"
|
||||||
|
format="HH:mm"
|
||||||
|
value-format="HH"
|
||||||
|
placeholder="选择时间"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="最小间隔 (分钟)">
|
||||||
|
<el-input-number v-model="scheduleForm.task_interval_min" :min="1" :max="60" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="最大间隔 (分钟)">
|
||||||
|
<el-input-number v-model="scheduleForm.task_interval_max" :min="1" :max="120" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSaveSchedule" :loading="saving">保存配置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-divider />
|
||||||
|
|
||||||
|
<el-form-item label="任务状态">
|
||||||
|
<el-tag :type="schedulerStatus.is_running ? 'success' : 'info'">
|
||||||
|
{{ schedulerStatus.is_running ? '运行中' : '已停止' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
v-if="!schedulerStatus.is_running"
|
||||||
|
type="success"
|
||||||
|
@click="handleStartScheduler"
|
||||||
|
>
|
||||||
|
启动任务
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
type="warning"
|
||||||
|
@click="handleStopScheduler"
|
||||||
|
>
|
||||||
|
停止任务
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 限额设置 -->
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>限额设置</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :model="limitForm" label-width="160px">
|
||||||
|
<el-form-item label="每日 Token 上限">
|
||||||
|
<el-input-number v-model="limitForm.max_tokens_per_day" :min="0" :step="1000" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="单用户日评论上限">
|
||||||
|
<el-input-number v-model="limitForm.max_comments_per_user_per_day" :min="0" :max="100" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="单用户日回复上限">
|
||||||
|
<el-input-number v-model="limitForm.max_replies_per_user_per_day" :min="0" :max="50" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSaveLimits" :loading="saving">保存配置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 互动概率设置 -->
|
||||||
|
<el-card style="margin-top: 20px;">
|
||||||
|
<template #header>
|
||||||
|
<span>互动概率设置</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :model="probabilityForm" label-width="160px" inline>
|
||||||
|
<el-form-item label="点赞概率">
|
||||||
|
<el-slider v-model="probabilityForm.like_probability" :min="0" :max="1" :step="0.1" show-input />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="收藏概率">
|
||||||
|
<el-slider v-model="probabilityForm.favorite_probability" :min="0" :max="1" :step="0.1" show-input />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="转发概率">
|
||||||
|
<el-slider v-model="probabilityForm.share_probability" :min="0" :max="1" :step="0.1" show-input />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSaveProbabilities" :loading="saving">保存配置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { systemApi } from '@/api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const scheduleForm = ref({
|
||||||
|
task_start_hour: '9',
|
||||||
|
task_end_hour: '22',
|
||||||
|
task_interval_min: 10,
|
||||||
|
task_interval_max: 30
|
||||||
|
})
|
||||||
|
|
||||||
|
const limitForm = ref({
|
||||||
|
max_tokens_per_day: 10000,
|
||||||
|
max_comments_per_user_per_day: 20,
|
||||||
|
max_replies_per_user_per_day: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
const probabilityForm = ref({
|
||||||
|
like_probability: 0.8,
|
||||||
|
favorite_probability: 0.5,
|
||||||
|
share_probability: 0.3
|
||||||
|
})
|
||||||
|
|
||||||
|
const schedulerStatus = ref({
|
||||||
|
is_running: false,
|
||||||
|
jobs: []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const [schedule, limits, probabilities, status] = await Promise.all([
|
||||||
|
systemApi.getSchedule(),
|
||||||
|
systemApi.getLimits(),
|
||||||
|
systemApi.getProbabilities(),
|
||||||
|
systemApi.getSchedulerStatus()
|
||||||
|
])
|
||||||
|
|
||||||
|
scheduleForm.value = schedule
|
||||||
|
limitForm.value = limits
|
||||||
|
probabilityForm.value = probabilities
|
||||||
|
schedulerStatus.value = status
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load config error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存调度配置
|
||||||
|
const handleSaveSchedule = async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await systemApi.updateSchedule(scheduleForm.value)
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save schedule error:', error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存限额配置
|
||||||
|
const handleSaveLimits = async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await systemApi.updateLimits(limitForm.value)
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save limits error:', error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存概率配置
|
||||||
|
const handleSaveProbabilities = async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
// TODO: 实现概率配置 API
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save probabilities error:', error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动调度器
|
||||||
|
const handleStartScheduler = async () => {
|
||||||
|
try {
|
||||||
|
await systemApi.startScheduler()
|
||||||
|
ElMessage.success('任务已启动')
|
||||||
|
loadConfig()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Start scheduler error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止调度器
|
||||||
|
const handleStopScheduler = async () => {
|
||||||
|
try {
|
||||||
|
await systemApi.stopScheduler()
|
||||||
|
ElMessage.success('任务已停止')
|
||||||
|
loadConfig()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Stop scheduler error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadConfig()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
373
frontend/src/views/VirtualUsers.vue
Normal file
373
frontend/src/views/VirtualUsers.vue
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
<template>
|
||||||
|
<div class="virtual-users">
|
||||||
|
<el-card>
|
||||||
|
<!-- 操作栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索用户名或昵称"
|
||||||
|
style="width: 200px; margin-right: 10px;"
|
||||||
|
clearable
|
||||||
|
@clear="loadData"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="handleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
|
||||||
|
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 120px;" @change="loadData">
|
||||||
|
<el-option label="启用" value="active" />
|
||||||
|
<el-option label="禁用" value="disabled" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<el-button type="primary" @click="showGenerateDialog = true">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
批量生成
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button type="success" @click="triggerImport">
|
||||||
|
<el-icon><Upload /></el-icon>
|
||||||
|
Excel 导入
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="importFileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleImport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户列表 -->
|
||||||
|
<el-table
|
||||||
|
:data="userList"
|
||||||
|
v-loading="loading"
|
||||||
|
style="width: 100%; margin-top: 20px;"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="55" />
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="avatar_url" label="头像" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-avatar :src="row.avatar_url" :size="40" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="nickname" label="昵称" width="150" />
|
||||||
|
<el-table-column prop="username" label="用户名" width="150" />
|
||||||
|
<el-table-column prop="writing_style" label="写作风格" width="120" />
|
||||||
|
<el-table-column prop="activity_level" label="活跃度" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="getActivityLevelTag(row.activity_level)">
|
||||||
|
{{ getActivityLevelLabel(row.activity_level) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">
|
||||||
|
{{ row.status === 'active' ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="total_interactions" label="互动次数" width="100" />
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.created_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" fixed="right" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="pagination.page"
|
||||||
|
v-model:page-size="pagination.pageSize"
|
||||||
|
:total="pagination.total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="loadData"
|
||||||
|
@size-change="loadData"
|
||||||
|
style="margin-top: 20px; justify-content: flex-end;"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 批量生成对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showGenerateDialog"
|
||||||
|
title="批量生成虚拟用户"
|
||||||
|
width="500px"
|
||||||
|
>
|
||||||
|
<el-form :model="generateForm" label-width="120px">
|
||||||
|
<el-form-item label="生成数量">
|
||||||
|
<el-input-number v-model="generateForm.count" :min="1" :max="100" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="写作风格">
|
||||||
|
<el-select v-model="generateForm.writing_styles" multiple placeholder="不选则随机">
|
||||||
|
<el-option label="幽默风趣" value="幽默风趣" />
|
||||||
|
<el-option label="严肃理性" value="严肃理性" />
|
||||||
|
<el-option label="文艺清新" value="文艺清新" />
|
||||||
|
<el-option label="吐槽犀利" value="吐槽犀利" />
|
||||||
|
<el-option label="感性温暖" value="感性温暖" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="活跃度">
|
||||||
|
<el-checkbox-group v-model="generateForm.activity_levels">
|
||||||
|
<el-checkbox label="low">低</el-checkbox>
|
||||||
|
<el-checkbox label="medium">中</el-checkbox>
|
||||||
|
<el-checkbox label="high">高</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="AI 人格描述">
|
||||||
|
<el-switch v-model="generateForm.generate_persona" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showGenerateDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleGenerate" :loading="generating">生成</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 编辑对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showEditDialog"
|
||||||
|
title="编辑虚拟用户"
|
||||||
|
width="600px"
|
||||||
|
>
|
||||||
|
<el-form :model="editForm" label-width="120px">
|
||||||
|
<el-form-item label="昵称">
|
||||||
|
<el-input v-model="editForm.nickname" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input v-model="editForm.username" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input v-model="editForm.password" type="password" show-password placeholder="不修改则留空" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="写作风格">
|
||||||
|
<el-select v-model="editForm.writing_style">
|
||||||
|
<el-option label="幽默风趣" value="幽默风趣" />
|
||||||
|
<el-option label="严肃理性" value="严肃理性" />
|
||||||
|
<el-option label="文艺清新" value="文艺清新" />
|
||||||
|
<el-option label="吐槽犀利" value="吐槽犀利" />
|
||||||
|
<el-option label="感性温暖" value="感性温暖" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="活跃度">
|
||||||
|
<el-radio-group v-model="editForm.activity_level">
|
||||||
|
<el-radio label="low">低</el-radio>
|
||||||
|
<el-radio label="medium">中</el-radio>
|
||||||
|
<el-radio label="high">高</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-radio-group v-model="editForm.status">
|
||||||
|
<el-radio label="active">启用</el-radio>
|
||||||
|
<el-radio label="disabled">禁用</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showEditDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmitEdit" :loading="saving">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { virtualUserApi } from '@/api'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const generating = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const userList = ref([])
|
||||||
|
const showGenerateDialog = ref(false)
|
||||||
|
const showEditDialog = ref(false)
|
||||||
|
const importFileInput = ref(null)
|
||||||
|
|
||||||
|
const pagination = ref({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const generateForm = ref({
|
||||||
|
count: 10,
|
||||||
|
writing_styles: [],
|
||||||
|
activity_levels: ['low', 'medium', 'high'],
|
||||||
|
generate_persona: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const editForm = ref({})
|
||||||
|
const currentUserId = ref(null)
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: pagination.value.page,
|
||||||
|
page_size: pagination.value.pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchKeyword.value) {
|
||||||
|
params.search = searchKeyword.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter.value) {
|
||||||
|
params.status = statusFilter.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await virtualUserApi.getList(params)
|
||||||
|
userList.value = res.items || []
|
||||||
|
pagination.value.total = res.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load user list error:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.value.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量生成
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
generating.value = true
|
||||||
|
try {
|
||||||
|
await virtualUserApi.generate(generateForm.value)
|
||||||
|
ElMessage.success(`成功生成 ${generateForm.value.count} 个虚拟用户`)
|
||||||
|
showGenerateDialog.value = false
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Generate users error:', error)
|
||||||
|
} finally {
|
||||||
|
generating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excel 导入
|
||||||
|
const triggerImport = () => {
|
||||||
|
importFileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = async (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await virtualUserApi.import(file, true)
|
||||||
|
ElMessage.success(`导入完成:成功${res.success_count}个,失败${res.failed_count}个`)
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Import users error:', error)
|
||||||
|
} finally {
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
const handleEdit = (row) => {
|
||||||
|
currentUserId.value = row.id
|
||||||
|
editForm.value = { ...row }
|
||||||
|
showEditDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交编辑
|
||||||
|
const handleSubmitEdit = async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const data = { ...editForm.value }
|
||||||
|
if (!data.password) {
|
||||||
|
delete data.password
|
||||||
|
}
|
||||||
|
await virtualUserApi.update(currentUserId.value, data)
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
showEditDialog.value = false
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update user error:', error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = (row) => {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
`确定要删除虚拟用户 "${row.nickname}" 吗?`,
|
||||||
|
'警告',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
).then(async () => {
|
||||||
|
try {
|
||||||
|
await virtualUserApi.delete(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete user error:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
const getActivityLevelLabel = (level) => {
|
||||||
|
const map = { low: '低', medium: '中', high: '高' }
|
||||||
|
return map[level] || level
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActivityLevelTag = (level) => {
|
||||||
|
const map = { low: 'info', medium: '', high: 'success' }
|
||||||
|
return map[level] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time) => {
|
||||||
|
if (!time) return '-'
|
||||||
|
return new Date(time).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.virtual-users {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left, .toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
21
frontend/vite.config.js
Normal file
21
frontend/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
89
start.sh
Executable file
89
start.sh
Executable file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 会会虚拟用户 AI 互动系统 - 快速启动脚本
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "======================================"
|
||||||
|
echo " 会会虚拟用户 AI 互动系统"
|
||||||
|
echo " 快速启动脚本"
|
||||||
|
echo "======================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查 Docker 是否安装
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "错误:未检测到 Docker,请先安装 Docker"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 Docker Compose 是否安装
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
echo "错误:未检测到 Docker Compose,请先安装 Docker Compose"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Docker 版本:$(docker --version)"
|
||||||
|
echo "✓ Docker Compose 版本:$(docker-compose --version)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 创建必要的目录
|
||||||
|
echo "正在创建必要的目录..."
|
||||||
|
mkdir -p data/mysql data/logs data/uploads
|
||||||
|
chmod -R 755 data/
|
||||||
|
|
||||||
|
# 检查 .env 文件
|
||||||
|
if [ ! -f backend/.env ]; then
|
||||||
|
echo "正在创建 .env 配置文件..."
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
echo "⚠️ 请编辑 backend/.env 文件,配置必要参数(特别是 AI 模型 API Key)"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 询问是否重新构建
|
||||||
|
read -p "是否重新构建 Docker 镜像?(y/n): " rebuild
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "正在启动服务..."
|
||||||
|
|
||||||
|
if [ "$rebuild" = "y" ] || [ "$rebuild" = "Y" ]; then
|
||||||
|
docker-compose build
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "======================================"
|
||||||
|
echo " 服务启动完成!"
|
||||||
|
echo "======================================"
|
||||||
|
echo ""
|
||||||
|
echo "服务访问地址:"
|
||||||
|
echo " - 前端界面:http://localhost"
|
||||||
|
echo " - 后端 API: http://localhost:8000"
|
||||||
|
echo " - API 文档:http://localhost:8000/docs"
|
||||||
|
echo ""
|
||||||
|
echo "查看日志:"
|
||||||
|
echo " docker-compose logs -f"
|
||||||
|
echo ""
|
||||||
|
echo "停止服务:"
|
||||||
|
echo " docker-compose down"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 等待服务启动
|
||||||
|
echo "等待服务启动..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
if curl -s http://localhost:8000/health > /dev/null; then
|
||||||
|
echo "✓ 后端服务运行正常"
|
||||||
|
else
|
||||||
|
echo "⚠️ 后端服务可能还未完全启动,请稍后检查"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "首次使用请执行以下操作:"
|
||||||
|
echo "1. 访问 http://localhost:8000/docs 查看 API 文档"
|
||||||
|
echo "2. 在 AI 模型配置页面添加您的 AI 模型 API Key"
|
||||||
|
echo "3. 在虚拟用户管理页面生成或导入虚拟用户"
|
||||||
|
echo "4. 在系统设置页面配置活动时间和限额"
|
||||||
|
echo "5. 启动定时任务开始自动互动"
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user