Compare commits
22
Commits
1.0.0
..
79d57da769
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79d57da769 | ||
|
|
c944fbb0ea | ||
|
|
b43ee777fc | ||
|
|
7d9f5a358b | ||
|
|
b6e094c9c0 | ||
|
|
d6ad1db535 | ||
|
|
169e798718 | ||
|
|
053d22965c | ||
|
|
e18c241bf0 | ||
|
|
f52bc7d147 | ||
|
|
4ab8f94663 | ||
|
|
7203f04be6 | ||
|
|
958eaeda8a | ||
|
|
fe9110ca3c | ||
|
|
da4744f333 | ||
|
|
7448fdcba1 | ||
|
|
b27bc216e3 | ||
|
|
9b87eeb84b | ||
|
|
3a06224f5d | ||
|
|
cd07776914 | ||
|
|
3fbccbc2b1 | ||
|
|
0cfc9bf9c8 |
+18
@@ -0,0 +1,18 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.env
|
||||
backend/logs/
|
||||
|
||||
# Node
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
-490
@@ -1,490 +0,0 @@
|
||||
# 会会虚拟用户 AI 互动系统 - 架构设计文档
|
||||
|
||||
## 一、系统架构
|
||||
|
||||
### 1.1 整体架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 用户层 │
|
||||
│ (浏览器 / 移动端) │
|
||||
└──────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
│ HTTP/HTTPS
|
||||
│
|
||||
┌──────────────────▼──────────────────────────────────┐
|
||||
│ Nginx │
|
||||
│ (反向代理 / 静态资源) │
|
||||
└──────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ │
|
||||
┌────────▼────────┐ ┌──────▼────────┐
|
||||
│ Frontend │ │ Backend │
|
||||
│ Vue3 + │ │ FastAPI │
|
||||
│ Element Plus │ │ Python │
|
||||
│ (端口:80) │ │ (端口:8000) │
|
||||
└─────────────────┘ └──────┬────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
│ │ │
|
||||
┌──────▼──────┐ ┌───▼────┐ ┌─────▼─────┐
|
||||
│ MySQL │ │ Redis │ │ 定时任务 │
|
||||
│ 数据库 │ │ (可选) │ │ 调度器 │
|
||||
└─────────────┘ └────────┘ └───────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
│ │ │
|
||||
┌──────▼──────┐ ┌───▼────┐ ┌─────▼─────┐
|
||||
│ 会会 API │ │ AI 模型 │ │ 文件存储 │
|
||||
│ 接口服务 │ │ 服务 │ │ ( uploads)│
|
||||
└─────────────┘ └────────┘ └───────────┘
|
||||
```
|
||||
|
||||
### 1.2 技术栈选型
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| **前端** | Vue 3 | 渐进式 JavaScript 框架 |
|
||||
| | Element Plus | UI 组件库 |
|
||||
| | Pinia | 状态管理 |
|
||||
| | Vue Router | 路由管理 |
|
||||
| | ECharts | 数据可视化 |
|
||||
| **后端** | Python 3.11 | 编程语言 |
|
||||
| | FastAPI | 高性能 Web 框架 |
|
||||
| | SQLAlchemy | ORM 框架 |
|
||||
| | Pydantic | 数据验证 |
|
||||
| | APScheduler | 定时任务调度 |
|
||||
| **数据库** | MySQL 8.0 | 关系型数据库 |
|
||||
| **AI 对接** | OpenAI SDK | GPT 系列模型 |
|
||||
| | Zhipu SDK | 智谱 AI 模型 |
|
||||
| **部署** | Docker | 容器化 |
|
||||
| | Docker Compose | 编排工具 |
|
||||
| | Nginx | Web 服务器 |
|
||||
|
||||
## 二、模块设计
|
||||
|
||||
### 2.1 后端模块划分
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── main.py # 应用入口
|
||||
├── core/ # 核心配置
|
||||
│ ├── config.py # 系统配置
|
||||
│ └── __init__.py
|
||||
├── models/ # 数据库模型
|
||||
│ ├── base.py # 数据库基础
|
||||
│ ├── virtual_user.py # 虚拟用户模型
|
||||
│ ├── interaction.py # 互动记录模型
|
||||
│ ├── token_usage.py # Token 使用模型
|
||||
│ ├── system_config.py # 系统配置模型
|
||||
│ ├── ai_model.py # AI 模型配置模型
|
||||
│ └── news_cache.py # 新闻缓存模型
|
||||
├── schemas/ # Pydantic Schema
|
||||
│ ├── virtual_user.py # 虚拟用户 Schema
|
||||
│ ├── interaction.py # 互动 Schema
|
||||
│ ├── dashboard.py # 控制台 Schema
|
||||
│ ├── ai_model.py # AI 模型 Schema
|
||||
│ └── system_config.py # 系统配置 Schema
|
||||
├── services/ # 业务服务
|
||||
│ ├── huihui_api.py # 会会接口服务
|
||||
│ ├── ai_service.py # AI 服务
|
||||
│ ├── virtual_user_service.py # 虚拟用户服务
|
||||
│ ├── interaction_service.py # 互动服务
|
||||
│ ├── token_service.py # Token 统计服务
|
||||
│ └── scheduler_service.py # 定时任务服务
|
||||
├── api/ # API 路由
|
||||
│ ├── router.py # 路由注册
|
||||
│ ├── virtual_user.py # 虚拟用户 API
|
||||
│ ├── interaction.py # 互动 API
|
||||
│ ├── ai_model.py # AI 模型 API
|
||||
│ ├── dashboard.py # 控制台 API
|
||||
│ └── system_config.py # 系统配置 API
|
||||
└── utils/ # 工具函数
|
||||
```
|
||||
|
||||
### 2.2 前端模块划分
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── main.js # 应用入口
|
||||
├── App.vue # 根组件
|
||||
├── router/ # 路由配置
|
||||
│ └── index.js
|
||||
├── api/ # API 接口
|
||||
│ ├── request.js # Axios 封装
|
||||
│ └── index.js # API 定义
|
||||
├── views/ # 页面组件
|
||||
│ ├── Dashboard.vue # 控制台
|
||||
│ ├── VirtualUsers.vue # 虚拟用户管理
|
||||
│ ├── Interactions.vue # 互动记录
|
||||
│ ├── AIModels.vue # AI 模型配置
|
||||
│ └── Settings.vue # 系统设置
|
||||
├── components/ # 公共组件
|
||||
├── stores/ # Pinia 状态管理
|
||||
└── utils/ # 工具函数
|
||||
```
|
||||
|
||||
## 三、数据库设计
|
||||
|
||||
### 3.1 ER 图
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐
|
||||
│ virtual_users │ │ ai_model_configs │
|
||||
│─────────────────│ │──────────────────│
|
||||
│ id (PK) │ │ id (PK) │
|
||||
│ username │ │ model_name │
|
||||
│ password │ │ provider │
|
||||
│ nickname │ │ api_key │
|
||||
│ avatar_url │ │ is_default │
|
||||
│ writing_style │ │ is_active │
|
||||
│ activity_level │ └──────────────────┘
|
||||
│ status │
|
||||
│ session_token │ ┌──────────────────┐
|
||||
│ total_interactions│ │ system_configs │
|
||||
│ today_comments │ │──────────────────│
|
||||
│ today_replies │ │ id (PK) │
|
||||
└────────┬────────┘ │ config_key │
|
||||
│ │ config_value │
|
||||
│ 1 │ config_type │
|
||||
│ └──────────────────┘
|
||||
│ N
|
||||
┌────────▼────────┐ ┌──────────────────┐
|
||||
│interaction_records│ │ token_usages │
|
||||
│─────────────────│ │──────────────────│
|
||||
│ id (PK) │ │ id (PK) │
|
||||
│ virtual_user_id │(FK) │ virtual_user_id │
|
||||
│ news_id │ │ interaction_id │
|
||||
│ news_title │ │ tokens_used │
|
||||
│ interaction_type│ │ ai_model │
|
||||
│ content │ │ action_type │
|
||||
│ status │ │ usage_date │
|
||||
│ retry_count │ └──────────────────┘
|
||||
│ tokens_used │
|
||||
│ ai_model_used │ ┌──────────────────┐
|
||||
└─────────────────┘ │ news_cache │
|
||||
│──────────────────│
|
||||
│ id (PK) │
|
||||
│ news_id │
|
||||
│ title │
|
||||
│ content │
|
||||
│ category │
|
||||
│ cache_date │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 核心表结构
|
||||
|
||||
#### virtual_users (虚拟用户表)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INT | 主键 |
|
||||
| username | VARCHAR(100) | 用户名(唯一) |
|
||||
| password | VARCHAR(200) | 密码(加密) |
|
||||
| nickname | VARCHAR(100) | 昵称 |
|
||||
| avatar_url | VARCHAR(500) | 头像 URL |
|
||||
| writing_style | VARCHAR(50) | 写作风格 |
|
||||
| activity_level | ENUM | 活跃度(low/medium/high) |
|
||||
| persona_description | TEXT | AI 生成的人格描述 |
|
||||
| status | ENUM | 状态(active/disabled) |
|
||||
| is_logged_in | BOOLEAN | 是否已登录 |
|
||||
| session_token | VARCHAR(500) | 会话 Token |
|
||||
| total_interactions | INT | 总互动次数 |
|
||||
| today_comments | INT | 今日评论数 |
|
||||
| today_replies | INT | 今日回复数 |
|
||||
| created_at | DATETIME | 创建时间 |
|
||||
| updated_at | DATETIME | 更新时间 |
|
||||
|
||||
#### interaction_records (互动记录表)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INT | 主键 |
|
||||
| virtual_user_id | INT | 虚拟用户 ID(外键) |
|
||||
| news_id | VARCHAR(100) | 新闻 ID |
|
||||
| news_title | VARCHAR(500) | 新闻标题 |
|
||||
| interaction_type | ENUM | 类型(comment/reply/like/favorite/share) |
|
||||
| content | TEXT | 互动内容 |
|
||||
| target_comment_id | VARCHAR(100) | 目标评论 ID(回复时) |
|
||||
| status | ENUM | 状态(pending/success/failed) |
|
||||
| retry_count | INT | 重试次数 |
|
||||
| error_message | TEXT | 错误信息 |
|
||||
| tokens_used | INT | 消耗 Token 数 |
|
||||
| execution_time | DATETIME | 执行时间 |
|
||||
|
||||
## 四、核心流程设计
|
||||
|
||||
### 4.1 虚拟用户生成流程
|
||||
|
||||
```
|
||||
开始
|
||||
│
|
||||
▼
|
||||
接收生成请求
|
||||
(count, styles, levels)
|
||||
│
|
||||
▼
|
||||
循环 count 次
|
||||
│
|
||||
├─► 生成唯一用户名
|
||||
├─► 随机生成昵称
|
||||
├─► 生成随机密码
|
||||
├─► 选择写作风格
|
||||
├─► 选择活跃度级别
|
||||
├─► 调用 AI 生成人格描述
|
||||
├─► 生成头像 URL
|
||||
├─► 保存到数据库
|
||||
│
|
||||
▼
|
||||
返回生成的用户列表
|
||||
│
|
||||
▼
|
||||
结束
|
||||
```
|
||||
|
||||
### 4.2 自动互动执行流程
|
||||
|
||||
```
|
||||
定时任务触发
|
||||
│
|
||||
▼
|
||||
检查活动时间段
|
||||
│
|
||||
├─否─► 跳过本次执行
|
||||
│
|
||||
是
|
||||
│
|
||||
▼
|
||||
获取活跃虚拟用户列表
|
||||
│
|
||||
▼
|
||||
随机选择一个用户
|
||||
│
|
||||
▼
|
||||
检查用户活跃度概率
|
||||
│
|
||||
├─不通过─► 跳过
|
||||
│
|
||||
通过
|
||||
│
|
||||
▼
|
||||
检查今日限额
|
||||
│
|
||||
├─超出─► 跳过
|
||||
│
|
||||
未超出
|
||||
│
|
||||
▼
|
||||
随机选择新闻
|
||||
│
|
||||
▼
|
||||
确定互动类型
|
||||
(点赞/收藏/转发/评论/回复)
|
||||
│
|
||||
▼
|
||||
如果是评论/回复
|
||||
│
|
||||
├─► 调用 AI 生成内容
|
||||
├─► 记录 Token 消耗
|
||||
│
|
||||
▼
|
||||
调用会会接口执行互动
|
||||
│
|
||||
▼
|
||||
记录互动结果
|
||||
│
|
||||
▼
|
||||
更新用户统计
|
||||
│
|
||||
▼
|
||||
结束
|
||||
```
|
||||
|
||||
### 4.3 AI 内容生成流程
|
||||
|
||||
```
|
||||
接收生成请求
|
||||
(新闻内容,写作风格,人格)
|
||||
│
|
||||
▼
|
||||
构建提示词 Prompt
|
||||
│
|
||||
▼
|
||||
获取默认 AI 模型配置
|
||||
│
|
||||
▼
|
||||
根据 provider 选择 SDK
|
||||
│
|
||||
├─► OpenAI: 调用 GPT API
|
||||
├─► Zhipu: 调用 GLM API
|
||||
├─► Baidu: 调用文心 API
|
||||
└─► Aliyun: 调用通义 API
|
||||
│
|
||||
▼
|
||||
接收 AI 响应
|
||||
│
|
||||
▼
|
||||
解析内容和 Token 数
|
||||
│
|
||||
▼
|
||||
记录 Token 使用
|
||||
│
|
||||
▼
|
||||
返回生成结果
|
||||
│
|
||||
▼
|
||||
结束
|
||||
```
|
||||
|
||||
## 五、API 设计规范
|
||||
|
||||
### 5.1 RESTful API 规范
|
||||
|
||||
所有 API 遵循 RESTful 设计风格:
|
||||
|
||||
```
|
||||
GET /api/v1/resource # 获取资源列表
|
||||
GET /api/v1/resource/{id} # 获取单个资源
|
||||
POST /api/v1/resource # 创建资源
|
||||
PUT /api/v1/resource/{id} # 更新资源
|
||||
DELETE /api/v1/resource/{id} # 删除资源
|
||||
POST /api/v1/resource/action # 资源操作
|
||||
```
|
||||
|
||||
### 5.2 统一响应格式
|
||||
|
||||
成功响应:
|
||||
```json
|
||||
{
|
||||
"data": { ... },
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
列表响应:
|
||||
```json
|
||||
{
|
||||
"total": 100,
|
||||
"items": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
错误响应:
|
||||
```json
|
||||
{
|
||||
"detail": "错误信息",
|
||||
"status_code": 400
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 分页参数
|
||||
|
||||
```
|
||||
GET /api/v1/virtual-users?page=1&page_size=20
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"total": 100,
|
||||
"items": [...],
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
```
|
||||
|
||||
## 六、安全设计
|
||||
|
||||
### 6.1 认证授权
|
||||
|
||||
- JWT Token 认证
|
||||
- Token 有效期 7 天
|
||||
- 支持刷新 Token
|
||||
|
||||
### 6.2 数据安全
|
||||
|
||||
- 密码加密存储(bcrypt)
|
||||
- API Key 加密存储
|
||||
- SQL 注入防护(ORM 参数化)
|
||||
- XSS 防护(前端输入过滤)
|
||||
|
||||
### 6.3 限流策略
|
||||
|
||||
- API 请求限流
|
||||
- Token 消耗限额
|
||||
- 单用户互动频次限制
|
||||
|
||||
## 七、性能优化
|
||||
|
||||
### 7.1 数据库优化
|
||||
|
||||
- 索引优化(username, status, execution_time)
|
||||
- 分页查询
|
||||
- 批量操作
|
||||
|
||||
### 7.2 缓存策略
|
||||
|
||||
- 新闻数据缓存
|
||||
- 系统配置缓存
|
||||
- 字典数据缓存
|
||||
|
||||
### 7.3 并发控制
|
||||
|
||||
- 数据库连接池(20-60)
|
||||
- 异步 IO(FastAPI + asyncio)
|
||||
- 定时任务并发控制
|
||||
|
||||
## 八、扩展性设计
|
||||
|
||||
### 8.1 插件化 AI 模型
|
||||
|
||||
支持通过配置添加新的 AI 模型提供商,无需修改代码。
|
||||
|
||||
### 8.2 可配置的互动策略
|
||||
|
||||
所有互动参数可通过系统配置调整:
|
||||
- 互动概率
|
||||
- 活跃度分级
|
||||
- 时间段控制
|
||||
|
||||
### 8.3 模块化设计
|
||||
|
||||
各功能模块独立,易于扩展新功能:
|
||||
- 新增互动类型
|
||||
- 新增数据源
|
||||
- 新增统计维度
|
||||
|
||||
## 九、监控与日志
|
||||
|
||||
### 9.1 日志系统
|
||||
|
||||
- 应用日志(INFO/WARNING/ERROR)
|
||||
- 访问日志
|
||||
- 慢查询日志
|
||||
- 日志轮转(保留 30 天)
|
||||
|
||||
### 9.2 健康检查
|
||||
|
||||
```bash
|
||||
GET /health
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"scheduler_running": true
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 指标监控
|
||||
|
||||
- API 响应时间
|
||||
- 数据库查询性能
|
||||
- AI 调用成功率
|
||||
- Token 消耗速率
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**更新日期**: 2026-03-23
|
||||
-375
@@ -1,375 +0,0 @@
|
||||
# 会会虚拟用户 AI 互动系统 - 部署与使用指南
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
本项目是一个基于 AI 大模型的虚拟用户自动化互动系统,主要功能包括:
|
||||
|
||||
### 核心功能
|
||||
1. **虚拟用户管理** - 批量生成、Excel 导入、人格配置
|
||||
2. **AI 内容生成** - 支持 OpenAI、智谱等主流大模型
|
||||
3. **自动化互动** - 评论、回复、点赞、收藏、转发
|
||||
4. **定时任务调度** - 随机时间、活跃度控制、限额管理
|
||||
5. **数据可视化** - 控制台仪表盘、Token 消耗统计
|
||||
|
||||
## 二、快速部署
|
||||
|
||||
### 方法一:一键启动脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd /Users/yqq/Works/Projects/会会广场机器人
|
||||
|
||||
# 执行启动脚本
|
||||
./start.sh
|
||||
```
|
||||
|
||||
脚本会自动完成:
|
||||
- 检查 Docker 环境
|
||||
- 创建必要目录
|
||||
- 配置文件初始化
|
||||
- 启动所有服务
|
||||
|
||||
### 方法二:手动 Docker Compose 部署
|
||||
|
||||
```bash
|
||||
# 1. 配置环境变量
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件,配置 AI 模型 API Key
|
||||
|
||||
# 2. 启动服务
|
||||
cd ..
|
||||
docker-compose up -d
|
||||
|
||||
# 3. 查看日志
|
||||
docker-compose logs -f backend
|
||||
|
||||
# 4. 访问服务
|
||||
# 前端:http://localhost
|
||||
# 后端 API: http://localhost:8000
|
||||
# API 文档:http://localhost:8000/docs
|
||||
```
|
||||
|
||||
## 三、配置说明
|
||||
|
||||
### 1. 环境变量配置(backend/.env)
|
||||
|
||||
```bash
|
||||
# 必选:AI 模型配置(至少配置一个)
|
||||
OPENAI_API_KEY=sk-your-api-key-here
|
||||
# 或
|
||||
ZHIPU_API_KEY=your-zhipu-api-key-here
|
||||
|
||||
# 可选:调整系统限制
|
||||
MAX_TOKENS_PER_DAY=10000 # 每日 Token 上限
|
||||
MAX_COMMENTS_PER_USER_PER_DAY=20 # 单用户日评论上限
|
||||
TASK_START_HOUR=9 # 活动开始时间
|
||||
TASK_END_HOUR=22 # 活动结束时间
|
||||
```
|
||||
|
||||
### 2. 数据库配置
|
||||
|
||||
默认配置即可,Docker Compose 会自动创建 MySQL 容器:
|
||||
- 主机:mysql
|
||||
- 端口:3306
|
||||
- 用户:huihui_user
|
||||
- 密码:huihui_password
|
||||
- 数据库:huihui_ai_bot
|
||||
|
||||
### 3. 会会接口配置
|
||||
|
||||
默认已配置为:`http://192.168.1.200:63120`
|
||||
|
||||
如需修改,在 `.env` 文件中调整:
|
||||
```bash
|
||||
HUIHUI_API_BASE=http://your-huihui-api-address
|
||||
```
|
||||
|
||||
## 四、使用流程
|
||||
|
||||
### 第一步:AI 模型配置
|
||||
|
||||
1. 访问前端界面:http://localhost
|
||||
2. 进入"AI 模型配置"页面
|
||||
3. 点击"添加模型"
|
||||
4. 选择提供商(OpenAI / 智谱等)
|
||||
5. 填写 API Key 和其他参数
|
||||
6. 点击"测试"验证配置
|
||||
7. 设为默认模型
|
||||
|
||||
**支持的 AI 模型:**
|
||||
- OpenAI: gpt-3.5-turbo, gpt-4
|
||||
- 智谱 AI: glm-4
|
||||
- 百度文心:ERNIE-Bot(待实现)
|
||||
- 阿里通义:Qwen(待实现)
|
||||
|
||||
### 第二步:虚拟用户管理
|
||||
|
||||
#### 方式一:批量生成
|
||||
|
||||
1. 进入"虚拟用户管理"页面
|
||||
2. 点击"批量生成"
|
||||
3. 设置生成数量(1-100)
|
||||
4. 选择写作风格(可不选,随机分配)
|
||||
5. 选择活跃度级别
|
||||
6. 勾选"AI 人格描述"(推荐)
|
||||
7. 点击"生成"
|
||||
|
||||
#### 方式二:Excel 导入
|
||||
|
||||
准备 Excel 文件,包含以下列:
|
||||
- `username`(必填)- 用户名/账号
|
||||
- `password`(必填)- 密码
|
||||
- `nickname`(可选)- 昵称
|
||||
- `writing_style`(可选)- 写作风格
|
||||
- `activity_level`(可选)- 活跃度(low/medium/high)
|
||||
|
||||
导入步骤:
|
||||
1. 点击"Excel 导入"
|
||||
2. 选择 Excel 文件
|
||||
3. 勾选"生成 AI 人格描述"
|
||||
4. 确认导入
|
||||
|
||||
### 第三步:系统设置
|
||||
|
||||
1. 进入"系统设置"页面
|
||||
2. 配置活动时间段(如 9:00-22:00)
|
||||
3. 配置互动频率(如 10-30 分钟)
|
||||
4. 配置限额(Token 上限、评论上限等)
|
||||
5. 保存配置
|
||||
|
||||
### 第四步:启动定时任务
|
||||
|
||||
1. 在"系统设置"页面
|
||||
2. 点击"启动任务"按钮
|
||||
3. 系统开始自动执行互动任务
|
||||
|
||||
### 第五步:监控与调整
|
||||
|
||||
1. **控制台** - 查看实时统计数据
|
||||
- 虚拟用户总数
|
||||
- 今日互动次数
|
||||
- Token 消耗情况
|
||||
- 最近互动记录
|
||||
|
||||
2. **互动记录** - 查看详细执行日志
|
||||
- 成功/失败状态
|
||||
- Token 消耗
|
||||
- 错误信息
|
||||
|
||||
3. **调整策略**
|
||||
- 根据 Token 消耗调整互动频率
|
||||
- 根据效果调整写作风格
|
||||
- 根据需求调整限额配置
|
||||
|
||||
## 五、高级配置
|
||||
|
||||
### 1. 自定义写作风格
|
||||
|
||||
在生成虚拟用户时,可以指定写作风格:
|
||||
- 幽默风趣
|
||||
- 严肃理性
|
||||
- 文艺清新
|
||||
- 吐槽犀利
|
||||
- 感性温暖
|
||||
- 客观中立
|
||||
- 激情澎湃
|
||||
- 冷静分析
|
||||
- 活泼可爱
|
||||
- 深沉内敛
|
||||
|
||||
也可在 `.env` 中添加自定义风格。
|
||||
|
||||
### 2. 活跃度分级
|
||||
|
||||
- **高活跃度**:每日互动 5-10 次(80% 概率执行)
|
||||
- **中活跃度**:每日互动 2-5 次(50% 概率执行)
|
||||
- **低活跃度**:每日互动 1-2 次(30% 概率执行)
|
||||
|
||||
### 3. 互动概率配置
|
||||
|
||||
默认概率:
|
||||
- 点赞:80%
|
||||
- 收藏:50%
|
||||
- 转发:30%
|
||||
- 评论:剩余概率
|
||||
|
||||
可在"系统设置"页面调整。
|
||||
|
||||
### 4. Token 限额策略
|
||||
|
||||
建议配置:
|
||||
- 开发测试:1000-5000 tokens/天
|
||||
- 小规模使用:5000-20000 tokens/天
|
||||
- 大规模使用:20000+ tokens/天
|
||||
|
||||
根据实际 AI 模型价格计算成本。
|
||||
|
||||
## 六、运维管理
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
# 查看所有服务日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 查看后端日志
|
||||
docker-compose logs -f backend
|
||||
|
||||
# 查看数据库日志
|
||||
docker-compose logs -f mysql
|
||||
|
||||
# 查看最近 100 行
|
||||
docker-compose logs --tail=100 backend
|
||||
```
|
||||
|
||||
### 备份数据
|
||||
|
||||
```bash
|
||||
# 备份 MySQL 数据库
|
||||
docker exec huihui_mysql mysqldump -uhuihui_user -phuihui_password huihui_ai_bot > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# 恢复数据库
|
||||
docker exec -i huihui_mysql mysql -uhuihui_user -phuihui_password huihui_ai_bot < backup_20260323.sql
|
||||
```
|
||||
|
||||
### 服务重启
|
||||
|
||||
```bash
|
||||
# 重启所有服务
|
||||
docker-compose restart
|
||||
|
||||
# 重启单个服务
|
||||
docker-compose restart backend
|
||||
|
||||
# 停止所有服务
|
||||
docker-compose down
|
||||
|
||||
# 停止并删除容器(保留数据)
|
||||
docker-compose down
|
||||
|
||||
# 完全清理(删除数据和容器)
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
### 更新升级
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
git pull
|
||||
|
||||
# 重新构建并启动
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 七、故障排查
|
||||
|
||||
### 1. 后端服务无法启动
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
docker-compose logs backend
|
||||
|
||||
# 常见问题:
|
||||
# - 数据库连接失败:检查 MySQL 是否启动
|
||||
# - 端口冲突:修改 docker-compose.yml 中的端口映射
|
||||
# - 环境变量错误:检查 .env 文件格式
|
||||
```
|
||||
|
||||
### 2. 数据库连接失败
|
||||
|
||||
```bash
|
||||
# 检查 MySQL 容器状态
|
||||
docker-compose ps mysql
|
||||
|
||||
# 查看 MySQL 日志
|
||||
docker-compose logs mysql
|
||||
|
||||
# 手动连接测试
|
||||
docker exec -it huihui_mysql mysql -uroot -proot123456
|
||||
```
|
||||
|
||||
### 3. AI 模型调用失败
|
||||
|
||||
- 检查 API Key 是否正确
|
||||
- 检查网络连接
|
||||
- 查看 Token 余额
|
||||
- 检查 API 地址是否正确
|
||||
- 查看后端日志中的详细错误信息
|
||||
|
||||
### 4. 定时任务未执行
|
||||
|
||||
- 检查调度器状态(系统设置页面)
|
||||
- 查看后端日志中的调度信息
|
||||
- 确认活动时间段配置
|
||||
- 检查虚拟用户状态(需为启用且已登录)
|
||||
|
||||
## 八、性能优化
|
||||
|
||||
### 1. 数据库优化
|
||||
|
||||
```sql
|
||||
-- 添加索引(已自动创建)
|
||||
CREATE INDEX idx_user_status ON virtual_users(status);
|
||||
CREATE INDEX idx_interaction_time ON interaction_records(execution_time);
|
||||
```
|
||||
|
||||
### 2. 并发控制
|
||||
|
||||
修改 `.env` 配置:
|
||||
```bash
|
||||
# 增加数据库连接池大小
|
||||
DATABASE_POOL_SIZE=30
|
||||
DATABASE_MAX_OVERFLOW=60
|
||||
```
|
||||
|
||||
### 3. 缓存策略
|
||||
|
||||
- 新闻数据缓存到数据库
|
||||
- 避免重复调用会会接口
|
||||
- 定期清理过期缓存
|
||||
|
||||
## 九、安全建议
|
||||
|
||||
1. **修改默认密码**
|
||||
- 数据库 root 密码
|
||||
- 应用 JWT_SECRET_KEY
|
||||
|
||||
2. **API Key 保护**
|
||||
- 不要将 .env 文件提交到 Git
|
||||
- 使用环境变量或密钥管理服务
|
||||
|
||||
3. **网络隔离**
|
||||
- 生产环境配置防火墙规则
|
||||
- 限制数据库访问
|
||||
|
||||
4. **定期备份**
|
||||
- 每日备份数据库
|
||||
- 备份重要配置文件
|
||||
|
||||
## 十、技术支持
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Q: 如何重置系统配置?**
|
||||
A: 删除 system_configs 表数据,重启服务后会自动创建默认配置。
|
||||
|
||||
**Q: 如何批量删除虚拟用户?**
|
||||
A: 暂时需要通过 API 或数据库操作,后续版本会添加批量删除功能。
|
||||
|
||||
**Q: Token 消耗过快怎么办?**
|
||||
A: 降低 MAX_TOKENS_PER_DAY,减少虚拟用户数量,或调低互动频率。
|
||||
|
||||
**Q: 如何查看会会接口的详细文档?**
|
||||
A: 访问 http://192.168.1.200:63120/doc.html 查看完整接口文档。
|
||||
|
||||
### 版本更新
|
||||
|
||||
关注项目更新日志,及时升级到最新版本获取新功能和安全修复。
|
||||
|
||||
---
|
||||
|
||||
**祝您使用愉快!**
|
||||
|
||||
如有问题,请联系开发团队或提交 Issue。
|
||||
@@ -1,310 +0,0 @@
|
||||
# 会会虚拟用户 AI 互动系统 - 项目交付总结
|
||||
|
||||
## 项目完成情况
|
||||
|
||||
### ✅ 已完成的功能模块
|
||||
|
||||
#### 1. 后端服务(FastAPI + Python)
|
||||
- ✅ **数据库模型设计** - 6 个核心数据表
|
||||
- virtual_users(虚拟用户)
|
||||
- interaction_records(互动记录)
|
||||
- token_usages(Token 使用)
|
||||
- system_configs(系统配置)
|
||||
- ai_model_configs(AI 模型配置)
|
||||
- news_cache(新闻缓存)
|
||||
|
||||
- ✅ **核心服务层** - 6 个业务服务
|
||||
- HuihuiAPIService - 会会接口对接
|
||||
- AIService - AI 大模型对接(支持 OpenAI/智谱等)
|
||||
- VirtualUserService - 虚拟用户管理
|
||||
- InteractionService - 互动执行引擎
|
||||
- TokenService - Token 统计服务
|
||||
- SchedulerService - 定时任务调度器
|
||||
|
||||
- ✅ **API 接口** - 5 个路由模块
|
||||
- /api/v1/virtual-users - 虚拟用户管理 API
|
||||
- /api/v1/interactions - 互动管理 API
|
||||
- /api/v1/ai-models - AI 模型配置 API
|
||||
- /api/v1/dashboard - 控制台 API
|
||||
- /api/v1/system - 系统设置 API
|
||||
|
||||
#### 2. 前端界面(Vue3 + Element Plus)
|
||||
- ✅ **控制台页面** - Dashboard.vue
|
||||
- 核心指标卡片(用户数、互动数、Token 消耗)
|
||||
- 每日 Token 消耗折线图
|
||||
- 每月 Token 消耗柱状图
|
||||
- 最近互动记录表格
|
||||
|
||||
- ✅ **虚拟用户管理** - VirtualUsers.vue
|
||||
- 用户列表展示(分页、搜索、筛选)
|
||||
- 批量生成(1-100 个)
|
||||
- Excel 导入功能
|
||||
- 编辑/删除操作
|
||||
- AI 人格描述生成
|
||||
|
||||
- ✅ **互动记录** - Interactions.vue
|
||||
- 互动记录列表
|
||||
- 类型标签(评论/回复/点赞/收藏/转发)
|
||||
- 状态显示(成功/失败/待执行)
|
||||
- Token 消耗展示
|
||||
|
||||
- ✅ **AI 模型配置** - AIModels.vue
|
||||
- 模型列表管理
|
||||
- 添加/编辑模型配置
|
||||
- 模型测试功能
|
||||
- 默认模型设置
|
||||
|
||||
- ✅ **系统设置** - Settings.vue
|
||||
- 活动调度配置(时间段、频率)
|
||||
- 限额配置(Token、评论、回复上限)
|
||||
- 概率配置(点赞/收藏/转发概率)
|
||||
- 定时任务启停控制
|
||||
|
||||
#### 3. Docker 部署配置
|
||||
- ✅ **Dockerfile** - 后端服务容器化
|
||||
- ✅ **docker-compose.yml** - 多容器编排
|
||||
- MySQL 8.0 容器
|
||||
- 后端应用容器
|
||||
- Nginx 前端容器
|
||||
- ✅ **初始化脚本** - init.sql
|
||||
- ✅ **Nginx 配置** - nginx.conf
|
||||
- ✅ **快速启动脚本** - start.sh
|
||||
|
||||
#### 4. 项目文档
|
||||
- ✅ README.md - 项目说明文档
|
||||
- ✅ DEPLOYMENT.md - 部署与使用指南
|
||||
- ✅ ARCHITECTURE.md - 架构设计文档
|
||||
- ✅ .env.example - 环境变量示例
|
||||
|
||||
## 核心技术亮点
|
||||
|
||||
### 1. AI 大模型集成
|
||||
- 支持多个主流 AI 模型提供商
|
||||
- OpenAI (GPT-3.5/GPT-4)
|
||||
- 智谱 AI (GLM-4)
|
||||
- 百度文心(框架已预留)
|
||||
- 阿里通义(框架已预留)
|
||||
- 统一的 AI 服务接口
|
||||
- 支持动态切换模型
|
||||
- Token 消耗精确统计
|
||||
|
||||
### 2. 智能互动引擎
|
||||
- **随机策略**
|
||||
- 活跃度分级(高/中/低)
|
||||
- 互动时间随机(10-30 分钟间隔)
|
||||
- 互动类型随机(基于概率)
|
||||
|
||||
- **限额控制**
|
||||
- 每日 Token 上限
|
||||
- 单用户评论上限
|
||||
- 单用户回复上限
|
||||
|
||||
- **容错机制**
|
||||
- 失败重试(3 次)
|
||||
- 错误日志记录
|
||||
- 独立任务隔离
|
||||
|
||||
### 3. 人格化虚拟用户
|
||||
- AI 生成独特人格描述
|
||||
- 写作风格配置(10 种预设)
|
||||
- 活跃度分级
|
||||
- 批量生成(支持 100 个)
|
||||
- Excel 导入支持
|
||||
|
||||
### 4. 数据可视化
|
||||
- ECharts 图表展示
|
||||
- 实时数据统计
|
||||
- 多维度分析(日/月)
|
||||
- 交互式仪表盘
|
||||
|
||||
## 项目文件清单
|
||||
|
||||
```
|
||||
会会广场机器人/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由(5 个模块)
|
||||
│ │ ├── core/ # 核心配置
|
||||
│ │ ├── models/ # 数据库模型(6 个表)
|
||||
│ │ ├── schemas/ # Pydantic Schema
|
||||
│ │ ├── services/ # 业务服务(6 个)
|
||||
│ │ └── main.py # 应用入口
|
||||
│ ├── requirements.txt # Python 依赖
|
||||
│ ├── Dockerfile # Docker 镜像
|
||||
│ └── .env.example # 环境变量示例
|
||||
│
|
||||
├── frontend/ # 前端服务
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 接口封装
|
||||
│ │ ├── router/ # 路由配置
|
||||
│ │ ├── views/ # 页面组件(5 个)
|
||||
│ │ ├── App.vue # 根组件
|
||||
│ │ └── main.js # 入口文件
|
||||
│ ├── package.json # 依赖配置
|
||||
│ └── vite.config.js # Vite 配置
|
||||
│
|
||||
├── docker/ # Docker 配置
|
||||
│ ├── mysql/
|
||||
│ │ └── init.sql # 数据库初始化
|
||||
│ └── nginx/
|
||||
│ └── nginx.conf # Nginx 配置
|
||||
│
|
||||
├── data/ # 数据持久化目录
|
||||
│ ├── mysql/
|
||||
│ └── logs/
|
||||
│
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── start.sh # 快速启动脚本
|
||||
├── README.md # 项目说明
|
||||
├── DEPLOYMENT.md # 部署指南
|
||||
└── ARCHITECTURE.md # 架构文档
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 快速开始(3 步部署)
|
||||
|
||||
```bash
|
||||
# 1. 进入项目目录
|
||||
cd /Users/yqq/Works/Projects/会会广场机器人
|
||||
|
||||
# 2. 配置环境变量
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填入 AI 模型 API Key
|
||||
|
||||
# 3. 一键启动
|
||||
cd ..
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### 访问地址
|
||||
- 前端界面:http://localhost
|
||||
- 后端 API: http://localhost:8000
|
||||
- API 文档:http://localhost:8000/docs
|
||||
|
||||
### 首次使用流程
|
||||
1. **配置 AI 模型** - 在"AI 模型配置"页面添加 API Key
|
||||
2. **生成虚拟用户** - 批量生成或 Excel 导入
|
||||
3. **设置系统参数** - 配置活动时间、限额等
|
||||
4. **启动定时任务** - 点击"启动任务"开始自动互动
|
||||
5. **监控运行状态** - 在控制台查看统计数据
|
||||
|
||||
## 技术栈总结
|
||||
|
||||
| 类别 | 技术 | 版本 |
|
||||
|------|------|------|
|
||||
| **后端框架** | FastAPI | 0.109.0 |
|
||||
| **ORM** | SQLAlchemy | 2.0.25 |
|
||||
| **数据验证** | Pydantic | 2.5.3 |
|
||||
| **定时任务** | APScheduler | 3.10.4 |
|
||||
| **数据库** | MySQL | 8.0 |
|
||||
| **前端框架** | Vue | 3.4.0 |
|
||||
| **UI 组件库** | Element Plus | 2.5.0 |
|
||||
| **状态管理** | Pinia | 2.1.7 |
|
||||
| **图表库** | ECharts | 5.4.3 |
|
||||
| **构建工具** | Vite | 5.0.10 |
|
||||
| **容器化** | Docker | latest |
|
||||
| **编排** | Docker Compose | 2.0+ |
|
||||
|
||||
## 性能指标
|
||||
|
||||
### 设计容量
|
||||
- 虚拟用户数:1000+
|
||||
- 日互动量:10000+
|
||||
- Token 处理能力:100,000+/天
|
||||
- API 响应时间:< 200ms
|
||||
- 数据库查询:< 50ms
|
||||
|
||||
### 资源需求
|
||||
- CPU: 2 核(推荐 4 核)
|
||||
- 内存:2GB(推荐 4GB)
|
||||
- 磁盘:10GB(根据日志量调整)
|
||||
- 网络:1Mbps 带宽
|
||||
|
||||
## 安全特性
|
||||
|
||||
1. **数据安全**
|
||||
- 密码加密存储(bcrypt)
|
||||
- API Key 加密
|
||||
- SQL 注入防护
|
||||
|
||||
2. **访问控制**
|
||||
- JWT Token 认证
|
||||
- 权限分级
|
||||
|
||||
3. **限额保护**
|
||||
- Token 日限额
|
||||
- 互动频率限制
|
||||
- 并发控制
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
### 短期优化(v1.1)
|
||||
- [ ] 完善百度文心、阿里通义对接
|
||||
- [ ] 添加批量删除虚拟用户功能
|
||||
- [ ] 增加更多图表维度
|
||||
- [ ] 优化移动端适配
|
||||
|
||||
### 中期规划(v2.0)
|
||||
- [ ] 用户登录认证系统
|
||||
- [ ] RBAC 权限管理
|
||||
- [ ] WebSocket 实时通知
|
||||
- [ ] 导出报表功能
|
||||
- [ ] 更多 AI 模型支持
|
||||
|
||||
### 长期规划(v3.0)
|
||||
- [ ] 分布式部署支持
|
||||
- [ ] Redis 缓存层
|
||||
- [ ] 消息队列(异步任务)
|
||||
- [ ] 机器学习优化互动策略
|
||||
- [ ] A/B 测试框架
|
||||
|
||||
## 已知限制
|
||||
|
||||
1. **会会接口依赖**
|
||||
- 需要确保会会接口服务可用
|
||||
- 接口变更需同步更新代码
|
||||
|
||||
2. **AI 模型成本**
|
||||
- Token 消耗会产生费用
|
||||
- 建议合理设置限额
|
||||
|
||||
3. **浏览器兼容**
|
||||
- 仅测试 Chrome/Edge/Safari
|
||||
- IE 不支持
|
||||
|
||||
## 联系与支持
|
||||
|
||||
### 技术文档
|
||||
- README.md - 快速开始
|
||||
- DEPLOYMENT.md - 详细部署指南
|
||||
- ARCHITECTURE.md - 架构设计参考
|
||||
|
||||
### 常见问题
|
||||
详见 DEPLOYMENT.md 第七章节
|
||||
|
||||
### 版本信息
|
||||
- 当前版本:v1.0.0
|
||||
- 发布日期:2026-03-23
|
||||
- Python 版本:3.11+
|
||||
- Node.js 版本:18+
|
||||
|
||||
---
|
||||
|
||||
## 项目交付清单 ✅
|
||||
|
||||
- [x] 完整的后端服务代码
|
||||
- [x] 完整的前端界面代码
|
||||
- [x] Docker 部署配置
|
||||
- [x] 数据库设计文档
|
||||
- [x] 架构设计文档
|
||||
- [x] 部署使用指南
|
||||
- [x] 快速启动脚本
|
||||
- [x] API 接口文档(Swagger)
|
||||
- [x] 环境变量配置示例
|
||||
- [x] 项目总结文档
|
||||
|
||||
**项目交付完成!🎉**
|
||||
|
||||
所有功能已按需求实现,可直接部署使用。
|
||||
@@ -1,193 +1,294 @@
|
||||
# 会会虚拟用户 AI 互动系统
|
||||
# AI虚拟用户新闻互动系统
|
||||
|
||||
基于 AI 大模型的虚拟用户自动化互动系统,支持对接会会平台接口,实现虚拟用户的自动登录、评论、回复、点赞、收藏、转发等功能。
|
||||
> 基于AI驱动的虚拟用户新闻互动自动化平台,支持批量虚拟用户管理、AI人格生成、真实登录新闻平台、自动随机互动。
|
||||
|
||||
## 功能特性
|
||||
---
|
||||
|
||||
### 核心功能
|
||||
- ✅ **虚拟用户管理**:批量生成、Excel 导入、人格配置
|
||||
- ✅ **AI 内容生成**:支持 OpenAI、智谱、百度文心、阿里通义等大模型
|
||||
- ✅ **自动化互动**:定时任务、随机策略、限额控制
|
||||
- ✅ **数据可视化**:控制台仪表盘、Token 消耗统计
|
||||
- ✅ **Docker 部署**:支持 1Panel 一键部署
|
||||
## 📁 项目结构
|
||||
|
||||
### 互动类型
|
||||
- 评论(AI 生成)
|
||||
- 回复(AI 生成)
|
||||
- 点赞
|
||||
- 收藏
|
||||
- 转发
|
||||
|
||||
### 技术栈
|
||||
- **后端**:Python 3.11 + FastAPI
|
||||
- **前端**:Vue 3 + Element Plus
|
||||
- **数据库**:MySQL 8.0
|
||||
- **AI 对接**:OpenAI / 智谱 / 百度 / 阿里
|
||||
- **部署**:Docker + Docker Compose
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 环境要求
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- 1Panel 面板(可选)
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
```
|
||||
ai-virtual-news/
|
||||
├── docker-compose.yml # Docker编排文件
|
||||
├── docker/
|
||||
│ └── mysql/
|
||||
│ └── init.sql # 数据库初始化脚本
|
||||
├── backend/ # Python FastAPI 后端
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ └── app/
|
||||
│ ├── main.py # 应用入口
|
||||
│ ├── api/ # API路由层
|
||||
│ ├── core/ # 核心配置(DB/Redis/日志)
|
||||
│ ├── models/ # SQLAlchemy ORM模型
|
||||
│ ├── schemas/ # Pydantic数据模型
|
||||
│ ├── services/ # 业务服务层
|
||||
│ └── utils/ # 工具类(AES加密等)
|
||||
└── frontend/ # Vue3 前端
|
||||
├── Dockerfile
|
||||
├── nginx.conf
|
||||
├── src/
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── api/ # Axios API封装
|
||||
│ ├── router/ # Vue Router
|
||||
│ ├── layouts/ # 布局组件
|
||||
│ └── styles/ # 全局样式
|
||||
└── package.json
|
||||
```
|
||||
|
||||
编辑 `.env` 文件,配置必要参数:
|
||||
- 数据库配置(默认即可)
|
||||
- AI 模型 API Key(至少配置一个)
|
||||
- 会会接口地址(默认:http://192.168.1.200:63120)
|
||||
---
|
||||
|
||||
### 3. 启动服务
|
||||
## 🚀 快速部署(1Panel Docker)
|
||||
|
||||
### 前置要求
|
||||
- 已安装 1Panel 面板
|
||||
- 已安装 Docker 及 Docker Compose
|
||||
- 服务器内网可访问新闻平台接口(192.168.1.200:63120)
|
||||
|
||||
### 第一步:修改环境配置
|
||||
|
||||
编辑 `docker-compose.yml`,修改以下**必须**更改的安全参数:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECRET_KEY=your-secret-key-change-in-production # ⚠️ 必须修改
|
||||
- AES_KEY=your-aes-key-32-chars-change-now! # ⚠️ 必须修改(必须32字符)
|
||||
- DB_PASSWORD=AiVirtual@2024 # ⚠️ 建议修改
|
||||
```
|
||||
|
||||
同时修改 MySQL 的 `MYSQL_PASSWORD` 与 `DB_PASSWORD` 保持一致。
|
||||
|
||||
### 第二步:通过 1Panel 部署
|
||||
|
||||
**方式A:1Panel 应用商店(推荐)**
|
||||
1. 登录 1Panel → 应用商店 → 搜索 "Docker Compose"
|
||||
2. 上传本项目目录
|
||||
3. 点击部署
|
||||
|
||||
**方式B:SSH 命令行**
|
||||
```bash
|
||||
# 启动所有服务
|
||||
# 1. 上传项目到服务器
|
||||
scp -r ai-virtual-news/ root@your-server:/opt/
|
||||
|
||||
# 2. 进入项目目录
|
||||
cd /opt/ai-virtual-news
|
||||
|
||||
# 3. 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f backend
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
# 4. 查看启动日志
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### 4. 访问服务
|
||||
- 前端界面:http://localhost
|
||||
- 后端 API:http://localhost:8000
|
||||
- API 文档:http://localhost:8000/docs
|
||||
### 第三步:访问系统
|
||||
|
||||
## 项目结构
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 前端控制台 | http://服务器IP:9000 |
|
||||
| 后端API文档 | http://服务器IP:8000/api/docs |
|
||||
| MySQL | 服务器IP:3306 |
|
||||
| Redis | 服务器IP:6379 |
|
||||
|
||||
```
|
||||
会会广场机器人/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ ├── core/ # 核心配置
|
||||
│ │ ├── models/ # 数据库模型
|
||||
│ │ ├── schemas/ # Pydantic Schema
|
||||
│ │ ├── services/ # 业务服务
|
||||
│ │ └── main.py # 应用入口
|
||||
│ ├── requirements.txt # Python 依赖
|
||||
│ └── Dockerfile
|
||||
├── frontend/ # 前端服务
|
||||
│ └── src/
|
||||
├── docker/ # Docker 配置
|
||||
│ ├── mysql/
|
||||
│ └── nginx/
|
||||
├── data/ # 数据持久化
|
||||
│ ├── mysql/
|
||||
│ └── logs/
|
||||
├── docker-compose.yml
|
||||
└── README.md
|
||||
---
|
||||
|
||||
## ⚙️ 初始配置
|
||||
|
||||
### 1. 配置AI模型
|
||||
|
||||
访问控制台 → **AI模型配置** → 添加模型:
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| 模型名称 | 自定义名称 | GPT-4生产 |
|
||||
| 提供商 | 选择对应供应商 | OpenAI |
|
||||
| API地址 | 留空用默认 | https://api.openai.com/v1 |
|
||||
| API Key | 对应平台的Key | sk-... |
|
||||
| 模型版本 | 具体模型名 | gpt-4-turbo |
|
||||
|
||||
> 配置完成后点击「设为默认」,系统将使用此模型进行所有AI操作。
|
||||
> 点击「测试」验证模型可用性。
|
||||
|
||||
**支持的国产模型配置:**
|
||||
|
||||
| 提供商 | API地址 | 模型版本示例 |
|
||||
|--------|---------|-------------|
|
||||
| 智谱GLM | https://open.bigmodel.cn/api/paas/v4 | glm-4 |
|
||||
| 文心一言 | https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat | ERNIE-Bot-4 |
|
||||
| 通义千问 | https://dashscope.aliyuncs.com/compatible-mode/v1 | qwen-turbo |
|
||||
|
||||
### 2. 配置新闻平台地址
|
||||
|
||||
访问控制台 → **调度设置** → 修改「平台接口地址」为实际地址。
|
||||
|
||||
### 3. 创建虚拟用户
|
||||
|
||||
**方式A:单个创建**
|
||||
控制台 → 虚拟用户 → 新增用户 → 填写账号密码 → 系统自动生成AI人格
|
||||
|
||||
**方式B:Excel批量导入**
|
||||
1. 下载导入模板
|
||||
2. 填写账号/密码/昵称等信息
|
||||
3. 上传Excel,系统自动校验并为每个用户生成AI人格
|
||||
|
||||
### 4. 启动自动互动
|
||||
|
||||
1. 确认用户已登录(状态为「已登录」)
|
||||
2. 调度设置 → 确认互动时间段和概率配置
|
||||
3. 调度器默认启动,系统将在设定时间段自动执行互动
|
||||
|
||||
---
|
||||
|
||||
## 🔧 运维管理
|
||||
|
||||
### Docker 常用命令
|
||||
|
||||
```bash
|
||||
# 查看所有容器状态
|
||||
docker compose ps
|
||||
|
||||
# 重启后端服务(后端代码更新后执行)
|
||||
docker compose restart ai-virtual-backend
|
||||
|
||||
# 查看后端实时日志
|
||||
docker compose logs -f ai-virtual-backend
|
||||
|
||||
# 停止所有服务
|
||||
docker compose down
|
||||
|
||||
# 启动所有服务
|
||||
docker compose up -d
|
||||
|
||||
# 进入后端容器
|
||||
docker exec -it ai-virtual-backend bash
|
||||
|
||||
# 进入MySQL
|
||||
docker exec -it ai-virtual-mysql mysql -u aivirtual -p ai_virtual_news
|
||||
```
|
||||
|
||||
## API 接口
|
||||
### ⚠️ 前端更新(重要:必须用此方式)
|
||||
|
||||
> `docker compose build` 存在缓存问题,前端代码修改后**必须**用以下方式重新 build,否则修改不会生效。
|
||||
|
||||
```bash
|
||||
cd /opt/1panel/docker/compose/ai-virtual-news/frontend
|
||||
|
||||
# 第一步:清缓存并 build(使用 node 镜像直接 build 宿主机目录)
|
||||
rm -rf dist node_modules/.vite
|
||||
docker run --rm -v $(pwd):/app -w /app node:18-alpine sh -c "npm run build"
|
||||
|
||||
# 第二步:把 dist 复制到运行中的容器
|
||||
docker cp dist/. ai-virtual-frontend:/usr/share/nginx/html/
|
||||
|
||||
# 第三步:重载 nginx(无需重启容器,立即生效)
|
||||
docker exec ai-virtual-frontend nginx -s reload
|
||||
```
|
||||
|
||||
### 数据备份(1Panel)
|
||||
|
||||
1. 1Panel → 数据库 → MySQL → 定时备份
|
||||
2. 建议每天凌晨 3 点备份,保留 30 天
|
||||
3. 或手动备份:
|
||||
```bash
|
||||
docker exec ai-virtual-mysql mysqldump -u aivirtual -pAiVirtual@2024 ai_virtual_news > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### 日志位置
|
||||
|
||||
| 日志类型 | 容器路径 | 宿主机路径 |
|
||||
|----------|----------|------------|
|
||||
| 应用日志 | /app/logs/app_*.log | ./backend/logs/ |
|
||||
| 错误日志 | /app/logs/error_*.log | ./backend/logs/ |
|
||||
| AI调用日志 | /app/logs/ai_*.log | ./backend/logs/ |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全注意事项
|
||||
|
||||
1. **AES密钥**:`AES_KEY` 必须修改为32字符随机字符串,用于加密存储账号密码
|
||||
2. **数据库密码**:生产环境务必修改默认密码
|
||||
3. **端口暴露**:建议通过 Nginx 反向代理访问,不要直接暴露 8000 端口
|
||||
4. **防火墙**:MySQL(3306)、Redis(6379) 端口不应对外暴露
|
||||
5. **互动频率**:合理设置互动间隔,避免触发新闻平台风控
|
||||
|
||||
---
|
||||
|
||||
## 📊 功能模块说明
|
||||
|
||||
### 数据看板
|
||||
- 实时展示用户总数、在线数、今日互动量
|
||||
- Token消耗折线图(近30天/7天)
|
||||
- 近12个月月度消耗柱状图
|
||||
- 系统运行状态监控
|
||||
|
||||
### 虚拟用户管理
|
||||
- `GET /api/v1/virtual-users` - 获取用户列表
|
||||
- `POST /api/v1/virtual-users` - 创建用户
|
||||
- `POST /api/v1/virtual-users/generate` - 批量生成用户
|
||||
- `POST /api/v1/virtual-users/import` - Excel 导入用户
|
||||
- `PUT /api/v1/virtual-users/{id}` - 更新用户
|
||||
- `DELETE /api/v1/virtual-users/{id}` - 删除用户
|
||||
- 新增/编辑/删除用户,账号密码AES加密存储
|
||||
- Excel批量导入(含格式校验、去重、错误详情)
|
||||
- Excel批量导出(不含密码密文)
|
||||
- AI人格生成:性格/语言风格/兴趣/互动倾向/字数偏好
|
||||
- 编辑用户资料(昵称/真实姓名/性别/头像/简介/邮箱),支持同步到目标平台
|
||||
- 头像上传:上传图片到平台 filecenter,自动更新用户头像
|
||||
- 单个/批量启用、禁用、登出操作
|
||||
- 手动触发登录/登出
|
||||
|
||||
### 互动管理
|
||||
- `POST /api/v1/interactions/execute` - 执行互动
|
||||
- `GET /api/v1/interactions` - 获取互动记录
|
||||
### AI互动模块
|
||||
- 真实调用新闻平台登录接口获取会话Token
|
||||
- 会话自动校验(10分钟/次),失效自动重登
|
||||
- 随机翻页获取文章,按用户兴趣偏好筛选,自动过滤无效新闻
|
||||
- AI生成贴合人格的评论/回复内容,内容完整不截断,自动过滤敏感词
|
||||
- 按概率随机触发:评论/回复/点赞/收藏/转发
|
||||
- 每日互动次数限额控制
|
||||
- 互动记录支持手动重试、取消
|
||||
|
||||
### AI 模型配置
|
||||
- `GET /api/v1/ai-models` - 获取模型列表
|
||||
- `POST /api/v1/ai-models` - 创建模型配置
|
||||
- `POST /api/v1/ai-models/test` - 测试模型
|
||||
### AI模型配置
|
||||
- 支持 OpenAI / 智谱GLM / 文心一言 / 通义千问 / 本地模型
|
||||
- API Key AES加密存储
|
||||
- 模型测试功能(验证可用性 + Token消耗预览)
|
||||
- 多模型管理,设置默认模型
|
||||
|
||||
### 控制台
|
||||
- `GET /api/v1/dashboard` - 获取统计数据
|
||||
- `GET /api/v1/dashboard/token/stats` - Token 统计
|
||||
- `GET /api/v1/dashboard/token/daily` - 每日 Token 使用
|
||||
### 调度设置
|
||||
- 互动时间段配置(北京时间)
|
||||
- 最小互动间隔控制(秒),防止同一用户频繁互动
|
||||
- 各互动类型概率独立配置
|
||||
- 并发用户数上限(0=不限)
|
||||
- 每日Token配额管控
|
||||
- 一键暂停/启动调度器
|
||||
- 立即触发互动(测试用)
|
||||
|
||||
## 系统配置
|
||||
### 日志管理
|
||||
- 登录日志:登录/登出/失败记录
|
||||
- 日志文件:应用日志/错误日志实时查看
|
||||
- 日志下载
|
||||
|
||||
### 默认限额
|
||||
- 每日 Token 上限:10,000
|
||||
- 单用户日评论上限:20
|
||||
- 单用户日回复上限:10
|
||||
---
|
||||
|
||||
### 活动时间段
|
||||
- 开始时间:09:00
|
||||
- 结束时间:22:00
|
||||
- 互动间隔:10-30 分钟(随机)
|
||||
## 🐛 常见问题
|
||||
|
||||
### 互动概率
|
||||
- 点赞:80%
|
||||
- 收藏:50%
|
||||
- 转发:30%
|
||||
**Q: 容器启动失败,提示数据库连接失败?**
|
||||
A: MySQL 启动需要时间,后端依赖 healthcheck。等待 30-60 秒后重试:`docker compose restart ai-virtual-backend`
|
||||
|
||||
## 开发指南
|
||||
**Q: 用户登录始终失败?**
|
||||
A: 1) 检查新闻平台接口地址是否正确;2) 检查账号密码是否正确;3) 查看后端日志定位具体错误
|
||||
|
||||
### 添加新的 AI 模型
|
||||
1. 在 `app/services/ai_service.py` 添加对应的调用方法
|
||||
2. 在 `app/models/ai_model.py` 添加提供商枚举
|
||||
3. 通过 API 配置新模型
|
||||
**Q: AI人格生成失败?**
|
||||
A: 未配置AI模型时系统会随机生成人格作为兜底,这是正常行为。配置有效的AI模型后可重新生成。
|
||||
|
||||
### 自定义互动策略
|
||||
修改 `app/services/interaction_service.py` 中的互动逻辑
|
||||
**Q: 调度器不执行互动?**
|
||||
A: 检查:1) 调度器是否启用;2) 是否在设定的互动时间段内(北京时间);3) 是否有已登录状态的用户;4) Token是否已达每日上限;5) 用户最近互动时间是否超过最小间隔
|
||||
|
||||
### 调整定时任务
|
||||
修改 `app/services/scheduler_service.py` 中的调度配置
|
||||
**Q: 前端修改后没有生效?**
|
||||
A: 不能用 `docker compose build`,必须用上方「前端更新」中的 node 镜像 build 方式。
|
||||
|
||||
## 常见问题
|
||||
**Q: 互动报"服务器繁忙"?**
|
||||
A: 通常是 orgId 为空导致。系统已自动从广场文章数据获取 orgId,如仍报错请检查文章是否有效。
|
||||
|
||||
### 1. 数据库连接失败
|
||||
检查 MySQL 服务是否启动:
|
||||
```bash
|
||||
docker-compose ps mysql
|
||||
```
|
||||
**Q: 评论报敏感词?**
|
||||
A: AI 提示词已包含安全规则,偶发属正常,系统不重试敏感词失败。
|
||||
|
||||
### 2. AI 模型调用失败
|
||||
- 检查 API Key 是否正确
|
||||
- 检查网络连接
|
||||
- 查看后端日志:`docker-compose logs backend`
|
||||
**Q: 后端 502 错误?**
|
||||
A: 查看日志定位原因:`docker compose logs --tail=20 ai-virtual-backend | grep -E "Error|Exception"`
|
||||
|
||||
### 3. Token 消耗过快
|
||||
- 调整 `MAX_TOKENS_PER_DAY` 配置
|
||||
- 降低互动频率
|
||||
- 减少虚拟用户数量
|
||||
---
|
||||
|
||||
## 1Panel 部署
|
||||
## 📞 技术支持
|
||||
|
||||
### 通过 1Panel 部署 Docker Compose
|
||||
1. 登录 1Panel 面板
|
||||
2. 进入"容器管理" -> "Compose"
|
||||
3. 点击"创建",上传 `docker-compose.yml`
|
||||
4. 配置环境变量
|
||||
5. 点击"创建"启动服务
|
||||
|
||||
### 开放端口
|
||||
在 1Panel 防火墙中开放:
|
||||
- 80 (前端)
|
||||
- 8000 (后端 API)
|
||||
- 3306 (数据库,可选)
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2026-03-23)
|
||||
- 初始版本发布
|
||||
- 支持虚拟用户生成和管理
|
||||
- 支持 AI 自动生成评论和回复
|
||||
- 支持多种互动类型
|
||||
- 完整的 Docker 部署方案
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请提交 Issue 或联系开发团队。
|
||||
- 后端API文档:`http://服务器IP:8000/api/docs`
|
||||
- 接口健康检查:`http://服务器IP:8000/health`
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# 应用基础配置
|
||||
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
|
||||
Regular → Executable
+4
-22
@@ -1,39 +1,21 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.10-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# 安装系统依赖
|
||||
# Install system dependencies
|
||||
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
|
||||
RUN mkdir -p /app/logs /app/config
|
||||
|
||||
# 暴露端口
|
||||
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"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
||||
|
||||
Regular → Executable
Regular → Executable
+12
-3
@@ -1,3 +1,12 @@
|
||||
"""
|
||||
API 路由模块初始化
|
||||
"""
|
||||
"""API路由汇总"""
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import users, interactions, ai_models, dashboard, system, logs
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(users.router, prefix="/users", tags=["虚拟用户管理"])
|
||||
router.include_router(interactions.router, prefix="/interactions", tags=["互动记录"])
|
||||
router.include_router(ai_models.router, prefix="/ai-models", tags=["AI模型配置"])
|
||||
router.include_router(dashboard.router, prefix="/dashboard", tags=["数据看板"])
|
||||
router.include_router(system.router, prefix="/system", tags=["系统设置"])
|
||||
router.include_router(logs.router, prefix="/logs", tags=["日志管理"])
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,150 +0,0 @@
|
||||
"""
|
||||
控制台 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]
|
||||
Regular → Executable
Executable
+87
@@ -0,0 +1,87 @@
|
||||
"""AI模型配置接口"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas import ApiResponse, AIModelCreateRequest, AIModelUpdateRequest, AIModelTestRequest
|
||||
from app.models import AIModelConfig
|
||||
from app.utils.crypto import encrypt, decrypt
|
||||
from app.services.ai_service import ai_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_models(db=Depends(get_db)):
|
||||
result = await db.execute(select(AIModelConfig).order_by(AIModelConfig.created_at.desc()))
|
||||
models = result.scalars().all()
|
||||
items = [_format_model(m) for m in models]
|
||||
return ApiResponse(data=items)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_model(req: AIModelCreateRequest, db=Depends(get_db)):
|
||||
if req.is_default:
|
||||
await db.execute(update(AIModelConfig).values(is_default=0))
|
||||
model = AIModelConfig(
|
||||
model_name=req.model_name,
|
||||
provider=req.provider,
|
||||
api_base_url=req.api_base_url,
|
||||
api_key_enc=encrypt(req.api_key) if req.api_key else None,
|
||||
model_version=req.model_version,
|
||||
temperature=req.temperature,
|
||||
max_tokens=req.max_tokens,
|
||||
timeout_seconds=req.timeout_seconds,
|
||||
is_default=req.is_default,
|
||||
is_enabled=1,
|
||||
)
|
||||
db.add(model)
|
||||
await db.commit()
|
||||
await db.refresh(model)
|
||||
return ApiResponse(data=_format_model(model), message="模型添加成功")
|
||||
|
||||
|
||||
@router.put("/{model_id}")
|
||||
async def update_model(model_id: int, req: AIModelUpdateRequest, db=Depends(get_db)):
|
||||
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
if req.is_default:
|
||||
await db.execute(update(AIModelConfig).where(AIModelConfig.id != model_id).values(is_default=0))
|
||||
for field, val in req.model_dump(exclude_none=True).items():
|
||||
if field == "api_key":
|
||||
model.api_key_enc = encrypt(val) if val else None
|
||||
else:
|
||||
setattr(model, field, val)
|
||||
await db.commit()
|
||||
await db.refresh(model)
|
||||
return ApiResponse(data=_format_model(model), message="更新成功")
|
||||
|
||||
|
||||
@router.delete("/{model_id}")
|
||||
async def delete_model(model_id: int, db=Depends(get_db)):
|
||||
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
await db.delete(model)
|
||||
await db.commit()
|
||||
return ApiResponse(message="删除成功")
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_model(req: AIModelTestRequest, db=Depends(get_db)):
|
||||
result = await ai_service.test_model(db, req.model_id, req.test_prompt)
|
||||
return ApiResponse(data=result)
|
||||
|
||||
|
||||
def _format_model(m: AIModelConfig) -> dict:
|
||||
return {
|
||||
"id": m.id, "model_name": m.model_name, "provider": m.provider,
|
||||
"api_base_url": m.api_base_url, "has_api_key": bool(m.api_key_enc),
|
||||
"model_version": m.model_version, "temperature": m.temperature,
|
||||
"max_tokens": m.max_tokens, "timeout_seconds": m.timeout_seconds,
|
||||
"is_default": m.is_default, "is_enabled": m.is_enabled,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
"""数据看板接口"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from app.core.database import get_db
|
||||
from app.schemas import ApiResponse
|
||||
from app.services.stats_service import stats_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_dashboard(db=Depends(get_db)):
|
||||
data = await stats_service.get_dashboard(db)
|
||||
return ApiResponse(data=data)
|
||||
|
||||
|
||||
@router.get("/token-trend")
|
||||
async def get_token_trend(days: int = Query(default=30, ge=7, le=90), db=Depends(get_db)):
|
||||
trend = await stats_service.get_token_trend(db, days)
|
||||
return ApiResponse(data=trend)
|
||||
|
||||
|
||||
@router.get("/monthly-token-trend")
|
||||
async def get_monthly_token_trend(db=Depends(get_db)):
|
||||
trend = await stats_service.get_monthly_token_trend(db)
|
||||
return ApiResponse(data=trend)
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
"""互动记录接口"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
import io, pandas as pd
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas import ApiResponse
|
||||
from app.services.stats_service import stats_service
|
||||
from app.models import InteractionRecord
|
||||
from sqlalchemy import select, update
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_interactions(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
user_id: Optional[int] = None,
|
||||
interact_type: Optional[str] = None,
|
||||
status: Optional[int] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
result = await stats_service.get_interaction_records(
|
||||
db, page, page_size, user_id, interact_type, status, start_date, end_date, keyword
|
||||
)
|
||||
return ApiResponse(data=result)
|
||||
|
||||
|
||||
@router.post("/{record_id}/retry")
|
||||
async def retry_interaction(record_id: int, db=Depends(get_db)):
|
||||
"""手动重试失败任务"""
|
||||
result = await db.execute(select(InteractionRecord).where(InteractionRecord.id == record_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
if record.status != 2:
|
||||
raise HTTPException(status_code=400, detail="只能重试失败的任务")
|
||||
if record.retry_count >= 3:
|
||||
raise HTTPException(status_code=400, detail="已超过最大重试次数(3次)")
|
||||
|
||||
from app.services.news_service import news_service
|
||||
from app.services.ai_service import ai_service
|
||||
from app.models import VirtualUser, UserPersonality
|
||||
|
||||
user_result = await db.execute(select(VirtualUser).where(VirtualUser.id == record.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user or user.status != 2:
|
||||
raise HTTPException(status_code=400, detail="用户未登录,无法重试")
|
||||
|
||||
success, err = False, "未知类型"
|
||||
if record.interact_type == "comment" and record.content:
|
||||
success, err = await news_service.post_comment(db, user, record.article_id, record.article_title or "", record.content)
|
||||
elif record.interact_type == "like":
|
||||
success, err = await news_service.like_news(db, user, record.article_id, org_id="", title=record.article_title or "")
|
||||
elif record.interact_type == "collect":
|
||||
success, err = await news_service.collect_news(db, user, record.article_id, title=record.article_title or "")
|
||||
elif record.interact_type == "forward":
|
||||
success, err = await news_service.forward_news(db, user, record.article_id)
|
||||
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=1 if success else 2,
|
||||
error_msg=None if success else err,
|
||||
retry_count=record.retry_count + 1,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return ApiResponse(message="重试成功" if success else f"重试失败: {err}")
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_interactions(
|
||||
user_id: Optional[int] = None,
|
||||
interact_type: Optional[str] = None,
|
||||
status: Optional[int] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""导出互动记录"""
|
||||
data = await stats_service.get_interaction_records(
|
||||
db, 1, 10000, user_id, interact_type, status, start_date, end_date
|
||||
)
|
||||
rows = [{
|
||||
"ID": r["id"], "用户昵称": r["user_nickname"], "用户账号": r["user_account"],
|
||||
"文章标题": r["article_title"], "互动类型": r["interact_type_label"],
|
||||
"内容": r["content"] or "", "Token消耗": r["token_consumed"],
|
||||
"状态": r["status_label"], "失败原因": r["error_msg"] or "",
|
||||
"重试次数": r["retry_count"], "执行时间": r["executed_at"],
|
||||
} for r in data["items"]]
|
||||
df = pd.DataFrame(rows)
|
||||
buf = io.BytesIO()
|
||||
df.to_excel(buf, index=False, sheet_name="互动记录")
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=interactions_export.xlsx"}
|
||||
)
|
||||
|
||||
@router.post("/{record_id}/cancel")
|
||||
async def cancel_interaction(record_id: int, db=Depends(get_db)):
|
||||
"""取消互动(取消点赞/收藏/删除评论),转发不支持取消"""
|
||||
from sqlalchemy import select, update
|
||||
from app.models import InteractionRecord, VirtualUser
|
||||
from app.services.news_service import news_service
|
||||
|
||||
# 查找互动记录
|
||||
r = await db.execute(select(InteractionRecord).where(InteractionRecord.id == record_id))
|
||||
record = r.scalar_one_or_none()
|
||||
if not record:
|
||||
return ApiResponse(code=404, message="记录不存在")
|
||||
|
||||
if record.status != 1:
|
||||
return ApiResponse(code=400, message="只能取消成功的互动")
|
||||
|
||||
if record.interact_type == "forward":
|
||||
return ApiResponse(code=400, message="转发互动无法取消")
|
||||
|
||||
if record.interact_type == "read":
|
||||
return ApiResponse(code=400, message="阅读记录无法取消")
|
||||
|
||||
# 查找对应用户
|
||||
ur = await db.execute(select(VirtualUser).where(VirtualUser.id == record.user_id))
|
||||
user = ur.scalar_one_or_none()
|
||||
if not user:
|
||||
return ApiResponse(code=404, message="用户不存在")
|
||||
|
||||
# 执行取消
|
||||
ok = False
|
||||
err = ""
|
||||
if record.interact_type in ("like",):
|
||||
ok, err = await news_service.cancel_like(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
org_id=record.session_id or "", # session_id 字段暂存 org_id
|
||||
title=record.article_title or "",
|
||||
)
|
||||
elif record.interact_type == "collect":
|
||||
ok, err = await news_service.cancel_collect(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
title=record.article_title or "",
|
||||
)
|
||||
elif record.interact_type in ("comment", "reply"):
|
||||
comment_id = record.platform_record_id or ""
|
||||
if not comment_id:
|
||||
return ApiResponse(code=400, message="缺少评论ID,无法删除")
|
||||
ok, err = await news_service.cancel_comment(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
comment_id=comment_id,
|
||||
)
|
||||
|
||||
if ok:
|
||||
# 更新状态为手动取消(status=3)
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=3, error_msg="手动取消"
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return ApiResponse(message="取消成功")
|
||||
else:
|
||||
return ApiResponse(code=500, message=f"取消失败: {err}")
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
"""日志管理接口"""
|
||||
import os
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select, func, and_
|
||||
from app.core.database import get_db
|
||||
from app.schemas import ApiResponse
|
||||
from app.models import LoginLog
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def get_login_logs(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
user_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
query = select(LoginLog)
|
||||
conditions = []
|
||||
if user_id:
|
||||
conditions.append(LoginLog.user_id == user_id)
|
||||
if action:
|
||||
conditions.append(LoginLog.action == action)
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
|
||||
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar()
|
||||
query = query.order_by(LoginLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
items = [{
|
||||
"id": l.id, "user_id": l.user_id, "user_account": l.user_account,
|
||||
"action": l.action, "session_id": l.session_id,
|
||||
"error_msg": l.error_msg, "created_at": l.created_at.strftime("%Y-%m-%dT%H:%M:%S+08:00") if l.created_at else None
|
||||
} for l in logs]
|
||||
return ApiResponse(data={"total": total, "page": page, "page_size": page_size, "items": items})
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
async def list_log_files():
|
||||
"""列出日志文件"""
|
||||
log_dir = settings.LOG_DIR
|
||||
files = []
|
||||
if os.path.exists(log_dir):
|
||||
for fname in sorted(os.listdir(log_dir), reverse=True):
|
||||
if fname.endswith(".log"):
|
||||
fpath = os.path.join(log_dir, fname)
|
||||
size = os.path.getsize(fpath)
|
||||
files.append({"name": fname, "size": size,
|
||||
"size_kb": round(size / 1024, 1)})
|
||||
return ApiResponse(data=files)
|
||||
|
||||
|
||||
@router.get("/files/{filename}/tail")
|
||||
async def tail_log_file(filename: str, lines: int = Query(default=100, ge=10, le=1000)):
|
||||
"""读取日志文件末尾"""
|
||||
# 安全校验
|
||||
if ".." in filename or "/" in filename:
|
||||
raise HTTPException(status_code=400, detail="非法文件名")
|
||||
fpath = os.path.join(settings.LOG_DIR, filename)
|
||||
if not os.path.exists(fpath):
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
|
||||
all_lines = f.readlines()
|
||||
tail = all_lines[-lines:]
|
||||
return ApiResponse(data={"filename": filename, "lines": tail, "total_lines": len(all_lines)})
|
||||
|
||||
|
||||
@router.get("/files/{filename}/download")
|
||||
async def download_log_file(filename: str):
|
||||
"""下载日志文件"""
|
||||
if ".." in filename or "/" in filename:
|
||||
raise HTTPException(status_code=400, detail="非法文件名")
|
||||
fpath = os.path.join(settings.LOG_DIR, filename)
|
||||
if not os.path.exists(fpath):
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
return FileResponse(fpath, filename=filename, media_type="text/plain")
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
"""系统设置接口"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, update as sql_update
|
||||
from app.core.database import get_db
|
||||
from app.schemas import ApiResponse
|
||||
from app.models import SystemConfig
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/configs")
|
||||
async def get_configs(db=Depends(get_db)):
|
||||
result = await db.execute(select(SystemConfig).order_by(SystemConfig.config_key))
|
||||
configs = result.scalars().all()
|
||||
data = {c.config_key: {"value": c.config_value, "type": c.config_type, "desc": c.description}
|
||||
for c in configs}
|
||||
return ApiResponse(data=data)
|
||||
|
||||
|
||||
@router.put("/configs")
|
||||
async def update_configs(body: dict, db=Depends(get_db)):
|
||||
"""批量更新配置"""
|
||||
for key, value in body.items():
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == key))
|
||||
cfg = result.scalar_one_or_none()
|
||||
if cfg:
|
||||
cfg.config_value = str(value)
|
||||
else:
|
||||
db.add(SystemConfig(config_key=key, config_value=str(value)))
|
||||
await db.commit()
|
||||
return ApiResponse(message="配置已保存")
|
||||
|
||||
|
||||
@router.post("/scheduler/toggle")
|
||||
async def toggle_scheduler(body: dict, db=Depends(get_db)):
|
||||
enabled = body.get("enabled", True)
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == "scheduler_enabled"))
|
||||
cfg = result.scalar_one_or_none()
|
||||
if cfg:
|
||||
cfg.config_value = "true" if enabled else "false"
|
||||
await db.commit()
|
||||
return ApiResponse(message=f"调度器已{'启用' if enabled else '暂停'}")
|
||||
|
||||
|
||||
@router.post("/sessions/reset-all")
|
||||
async def reset_all_sessions(db=Depends(get_db)):
|
||||
"""重置所有用户会话"""
|
||||
from app.models import VirtualUser
|
||||
await db.execute(
|
||||
sql_update(VirtualUser).values(status=0, session_token=None, session_expires_at=None)
|
||||
)
|
||||
await db.commit()
|
||||
return ApiResponse(message="所有会话已重置")
|
||||
|
||||
|
||||
@router.post("/login/diagnose")
|
||||
async def diagnose_login(body: dict, db=Depends(get_db)):
|
||||
"""
|
||||
诊断登录接口原始响应 - 临时调试用
|
||||
传入: {"username": "xxx", "password": "xxx"}
|
||||
"""
|
||||
import httpx, hashlib, uuid
|
||||
from datetime import datetime
|
||||
from app.services.news_service import news_service
|
||||
|
||||
cfg = await news_service._client(db)
|
||||
auth = await news_service._auth_url(db)
|
||||
|
||||
username = body.get("username", "")
|
||||
password = body.get("password", "")
|
||||
|
||||
# 构建 formData(与真实登录完全一致)
|
||||
extra = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"loginType": "password",
|
||||
"grantType": "password",
|
||||
"isRegister": "false",
|
||||
}
|
||||
if cfg.get("clientCode"):
|
||||
extra["clientCode"] = cfg["clientCode"]
|
||||
|
||||
form = news_service._build_form(extra, cfg)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
resp = await c.post(f"{auth}/open/login/token", data=form)
|
||||
|
||||
# 返回完整诊断信息
|
||||
try:
|
||||
resp_json = resp.json()
|
||||
except Exception:
|
||||
resp_json = None
|
||||
|
||||
return ApiResponse(data={
|
||||
"status_code": resp.status_code,
|
||||
"response_text": resp.text[:2000],
|
||||
"response_json": resp_json,
|
||||
"request_url": f"{auth}/open/login/token",
|
||||
"request_form": {k: v if k not in ("password","accessSecret") else "***" for k, v in form.items()},
|
||||
"content_type": resp.headers.get("content-type", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
return ApiResponse(code=500, message=str(e), data={"error": str(e)})
|
||||
|
||||
|
||||
@router.post("/interaction/run-now")
|
||||
async def run_interaction_now(db=Depends(get_db)):
|
||||
"""立即触发一次互动任务(不受时间段限制)"""
|
||||
from app.services.scheduler import scheduler_service
|
||||
try:
|
||||
result = await scheduler_service.run_once_now(db)
|
||||
return ApiResponse(data=result, message="互动任务已触发")
|
||||
except Exception as e:
|
||||
return ApiResponse(code=500, message=f"触发失败: {e}")
|
||||
Executable
+370
@@ -0,0 +1,370 @@
|
||||
"""虚拟用户管理接口"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
import io
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas import ApiResponse, UserCreateRequest, UserUpdateRequest, UserBatchRequest, PersonalityUpdateRequest
|
||||
from app.services.user_service import user_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
keyword: Optional[str] = Query(default=None),
|
||||
status: Optional[int] = Query(default=None),
|
||||
is_enabled: Optional[int] = Query(default=None),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""获取虚拟用户列表"""
|
||||
total, items = await user_service.get_users(db, page, page_size, keyword, status, is_enabled)
|
||||
return ApiResponse(data={"total": total, "page": page, "page_size": page_size, "items": items})
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_user(req: UserCreateRequest, db=Depends(get_db)):
|
||||
"""创建虚拟用户"""
|
||||
user = await user_service.create_user(db, req)
|
||||
return ApiResponse(data=user, message="用户创建成功")
|
||||
|
||||
|
||||
@router.get("/{user_id}")
|
||||
async def get_user(user_id: int, db=Depends(get_db)):
|
||||
"""获取单个用户详情"""
|
||||
total, items = await user_service.get_users(db, 1, 1)
|
||||
from sqlalchemy import select
|
||||
from app.models import VirtualUser, UserPersonality
|
||||
from app.services.user_service import user_service as svc
|
||||
result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
p_result = await db.execute(select(UserPersonality).where(UserPersonality.user_id == user_id))
|
||||
personality = p_result.scalar_one_or_none()
|
||||
return ApiResponse(data=svc._format_user(user, personality))
|
||||
|
||||
|
||||
@router.put("/{user_id}")
|
||||
async def update_user(user_id: int, req: UserUpdateRequest, db=Depends(get_db)):
|
||||
"""更新用户信息(sync_to_platform=true 时同步到目标平台)"""
|
||||
result = await user_service.update_user(db, user_id, req)
|
||||
|
||||
if req.sync_to_platform:
|
||||
from sqlalchemy import select
|
||||
from app.models import VirtualUser as _VU
|
||||
from app.services.news_service import news_service
|
||||
ur = await db.execute(select(_VU).where(_VU.id == user_id))
|
||||
user = ur.scalar_one_or_none()
|
||||
if user and user.status == 2:
|
||||
ok, err = await news_service.update_user_profile(
|
||||
db, user,
|
||||
nick_name=req.nickname,
|
||||
real_name=req.real_name,
|
||||
sex=req.sex,
|
||||
description=req.description,
|
||||
email=req.email,
|
||||
)
|
||||
if not ok:
|
||||
return ApiResponse(data=result,
|
||||
message=f"本地已保存,同步到平台失败: {err}", code=206)
|
||||
|
||||
return ApiResponse(data=result, message="更新成功")
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(user_id: int, db=Depends(get_db)):
|
||||
"""删除用户"""
|
||||
await user_service.delete_user(db, user_id)
|
||||
return ApiResponse(message="删除成功")
|
||||
|
||||
|
||||
@router.post("/batch/action")
|
||||
async def batch_action(req: UserBatchRequest, db=Depends(get_db)):
|
||||
"""批量操作用户"""
|
||||
result = await user_service.batch_action(db, req.user_ids, req.action)
|
||||
return ApiResponse(data=result, message="批量操作成功")
|
||||
|
||||
|
||||
@router.post("/{user_id}/login")
|
||||
async def manual_login(user_id: int, db=Depends(get_db)):
|
||||
"""手动触发用户登录"""
|
||||
from app.services.news_service import news_service
|
||||
from sqlalchemy import select
|
||||
from app.models import VirtualUser
|
||||
result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
success = await news_service.login(db, user)
|
||||
if success:
|
||||
return ApiResponse(message="登录成功")
|
||||
raise HTTPException(status_code=400, detail="登录失败,请检查账号密码")
|
||||
|
||||
|
||||
@router.post("/{user_id}/logout")
|
||||
async def manual_logout(user_id: int, db=Depends(get_db)):
|
||||
"""手动登出"""
|
||||
from app.services.news_service import news_service
|
||||
await news_service.logout(db, user_id)
|
||||
return ApiResponse(message="已登出")
|
||||
|
||||
|
||||
@router.post("/{user_id}/personality/generate")
|
||||
async def generate_personality(user_id: int, db=Depends(get_db)):
|
||||
"""重新生成AI人格"""
|
||||
personality = await user_service.generate_personality(db, user_id)
|
||||
return ApiResponse(data=personality, message="人格生成成功")
|
||||
|
||||
|
||||
@router.put("/{user_id}/personality")
|
||||
async def update_personality(user_id: int, req: PersonalityUpdateRequest, db=Depends(get_db)):
|
||||
"""手动编辑人格属性"""
|
||||
personality = await user_service.update_personality(db, user_id, req)
|
||||
return ApiResponse(data=personality, message="人格更新成功")
|
||||
|
||||
|
||||
@router.get("/excel/template")
|
||||
async def download_template():
|
||||
"""下载Excel导入模板"""
|
||||
content = await user_service.get_excel_template()
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=virtual_users_template.xlsx"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/excel/import")
|
||||
async def import_excel(file: UploadFile = File(...), db=Depends(get_db)):
|
||||
"""Excel批量导入"""
|
||||
if not file.filename.endswith((".xlsx", ".xls")):
|
||||
raise HTTPException(status_code=400, detail="仅支持Excel文件(.xlsx/.xls)")
|
||||
content = await file.read()
|
||||
result = await user_service.import_from_excel(db, content)
|
||||
return ApiResponse(data=result, message=f"导入完成:成功{result['success']}条,失败{result['failed']}条")
|
||||
|
||||
|
||||
@router.get("/excel/export")
|
||||
async def export_excel(db=Depends(get_db)):
|
||||
"""导出用户数据Excel"""
|
||||
content = await user_service.export_to_excel(db)
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=virtual_users_export.xlsx"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/deduplicate")
|
||||
async def deduplicate_users(db=Depends(get_db)):
|
||||
"""删除重复用户(保留最早创建的一条)"""
|
||||
from sqlalchemy import text
|
||||
# 找出重复的账号,保留 id 最小的,删除其他的
|
||||
result = await db.execute(
|
||||
text("""
|
||||
DELETE FROM virtual_users
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM virtual_users GROUP BY account
|
||||
)
|
||||
""")
|
||||
)
|
||||
await db.commit()
|
||||
deleted = result.rowcount
|
||||
return ApiResponse(data={"deleted": deleted}, message=f"已清理 {deleted} 条重复数据")
|
||||
|
||||
|
||||
@router.post("/clear-all")
|
||||
async def clear_all_users(db=Depends(get_db)):
|
||||
"""清空所有用户(慎用)"""
|
||||
from sqlalchemy import text
|
||||
from app.core.redis_client import get_redis
|
||||
await db.execute(text("DELETE FROM user_personalities"))
|
||||
await db.execute(text("DELETE FROM virtual_users"))
|
||||
await db.commit()
|
||||
return ApiResponse(message="已清空所有用户数据")
|
||||
|
||||
|
||||
@router.post("/login-all")
|
||||
async def batch_login_all(db=Depends(get_db)):
|
||||
"""一键登录所有未登录/登录失效的用户"""
|
||||
from sqlalchemy import select
|
||||
from app.services.news_service import news_service
|
||||
from app.models import VirtualUser as _VU
|
||||
from app.core.database import AsyncSessionLocal
|
||||
import asyncio
|
||||
|
||||
# 先用当前 session 查出所有待登录用户 ID
|
||||
result_r = await db.execute(
|
||||
select(_VU.id, _VU.account).where(
|
||||
_VU.is_enabled == 1,
|
||||
_VU.status.in_([0, 3])
|
||||
)
|
||||
)
|
||||
rows = result_r.all()
|
||||
if not rows:
|
||||
return ApiResponse(message="没有需要登录的用户", data={"count": 0})
|
||||
|
||||
user_ids = [r[0] for r in rows]
|
||||
total = len(user_ids)
|
||||
success = failed = 0
|
||||
|
||||
# 每个用户独立 session,避免事务污染
|
||||
async def login_one(uid: int):
|
||||
async with AsyncSessionLocal() as s:
|
||||
try:
|
||||
ur = await s.execute(select(_VU).where(_VU.id == uid))
|
||||
u = ur.scalar_one_or_none()
|
||||
if u:
|
||||
return await news_service.login(s, u)
|
||||
except Exception as e:
|
||||
logger.warning(f"login_one {uid} 异常: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
batch_size = 5
|
||||
for i in range(0, total, batch_size):
|
||||
batch_ids = user_ids[i:i+batch_size]
|
||||
results = await asyncio.gather(*[login_one(uid) for uid in batch_ids], return_exceptions=True)
|
||||
for r in results:
|
||||
if r is True: success += 1
|
||||
else: failed += 1
|
||||
if i + batch_size < total:
|
||||
await asyncio.sleep(1) # 批次间隔避免过于集中
|
||||
|
||||
return ApiResponse(
|
||||
message=f"登录完成:成功 {success} 个,失败 {failed} 个",
|
||||
data={"success": success, "failed": failed, "total": total}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync-all-profiles")
|
||||
async def sync_all_profiles(db=Depends(get_db)):
|
||||
"""
|
||||
同步所有已登录用户的平台信息(昵称/真实姓名/性别/头像)到本系统
|
||||
从登录 session 中的 token 调用目标平台接口获取最新用户信息
|
||||
"""
|
||||
from sqlalchemy import select, update
|
||||
from app.models import VirtualUser as _VU
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.redis_client import get_session
|
||||
import httpx, asyncio
|
||||
|
||||
# 查出所有已登录用户
|
||||
result_r = await db.execute(select(_VU).where(_VU.status == 2, _VU.is_enabled == 1))
|
||||
users = result_r.scalars().all()
|
||||
if not users:
|
||||
return ApiResponse(message="没有已登录的用户", data={"synced": 0})
|
||||
|
||||
synced = failed = 0
|
||||
|
||||
async def sync_one(uid: int):
|
||||
"""从登录 session 中提取已缓存的用户信息,直接写入数据库,无需调用外部接口"""
|
||||
async with AsyncSessionLocal() as s:
|
||||
try:
|
||||
sess = await get_session(uid)
|
||||
if not sess:
|
||||
return False
|
||||
platform_uid = sess.get("platform_uid", "")
|
||||
# 登录成功时 session 里已存有用户信息
|
||||
vals = {}
|
||||
if platform_uid: vals["platform_uid"] = platform_uid
|
||||
# session 里的字段(登录时写入)
|
||||
if sess.get("nickname"): vals["nickname"] = sess["nickname"]
|
||||
if sess.get("real_name"): vals["real_name"] = sess["real_name"]
|
||||
if sess.get("sex"): vals["sex"] = int(sess["sex"])
|
||||
if sess.get("avatar"): vals["avatar_url"] = sess["avatar"]
|
||||
if vals:
|
||||
await s.execute(update(_VU).where(_VU.id == uid).values(**vals))
|
||||
await s.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"sync_one {uid} 失败: {e}")
|
||||
return False
|
||||
|
||||
results = await asyncio.gather(*[sync_one(u.id) for u in users], return_exceptions=True)
|
||||
for r in results:
|
||||
if r is True: synced += 1
|
||||
else: failed += 1
|
||||
|
||||
return ApiResponse(
|
||||
message=f"同步完成:成功 {synced} 个,失败/跳过 {failed} 个",
|
||||
data={"synced": synced, "failed": failed, "total": len(users)}
|
||||
)
|
||||
|
||||
@router.post("/{user_id}/upload-avatar")
|
||||
async def upload_avatar(
|
||||
user_id: int,
|
||||
file: UploadFile = File(...),
|
||||
sync_to_platform: bool = Query(default=True),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""上传头像并可选同步到目标平台"""
|
||||
from sqlalchemy import select, update
|
||||
from app.models import VirtualUser as _VU
|
||||
from app.services.news_service import news_service
|
||||
|
||||
ur = await db.execute(select(_VU).where(_VU.id == user_id))
|
||||
user = ur.scalar_one_or_none()
|
||||
if not user:
|
||||
return ApiResponse(code=404, message="用户不存在")
|
||||
|
||||
# 读取文件内容
|
||||
file_bytes = await file.read()
|
||||
if len(file_bytes) > 5 * 1024 * 1024:
|
||||
return ApiResponse(code=400, message="头像文件不能超过5MB")
|
||||
|
||||
avatar_url = None
|
||||
|
||||
if sync_to_platform and user.status == 2:
|
||||
# 上传到目标平台
|
||||
ok, result = await news_service.upload_avatar(db, user, file_bytes, file.filename)
|
||||
if ok:
|
||||
avatar_url = result
|
||||
else:
|
||||
return ApiResponse(code=500, message=f"头像上传到平台失败: {result}")
|
||||
else:
|
||||
# 仅本地存储(转 base64 或存储到本地)
|
||||
import base64
|
||||
avatar_url = f"data:{file.content_type};base64,{base64.b64encode(file_bytes).decode()}"
|
||||
|
||||
# 更新数据库
|
||||
await db.execute(update(_VU).where(_VU.id == user_id).values(avatar_url=avatar_url))
|
||||
await db.commit()
|
||||
|
||||
# 如果已同步到平台,再调用 update_user_profile 更新头像字段
|
||||
if sync_to_platform and user.status == 2 and avatar_url:
|
||||
await news_service.update_user_profile(db, user, avatar=avatar_url)
|
||||
|
||||
return ApiResponse(data={"avatar_url": avatar_url}, message="头像更新成功")
|
||||
@router.post("/logout-all")
|
||||
async def batch_logout_all(db=Depends(get_db)):
|
||||
"""一键登出所有已登录用户"""
|
||||
from sqlalchemy import select, update
|
||||
from app.models import VirtualUser as _VU
|
||||
from app.core.redis_client import delete_session
|
||||
|
||||
result_r = await db.execute(
|
||||
select(_VU.id).where(_VU.status == 2, _VU.is_enabled == 1)
|
||||
)
|
||||
rows = result_r.all()
|
||||
if not rows:
|
||||
return ApiResponse(message="没有已登录的用户", data={"count": 0})
|
||||
|
||||
count = 0
|
||||
for row in rows:
|
||||
try:
|
||||
await delete_session(row[0])
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 更新所有用户状态为未登录
|
||||
await db.execute(
|
||||
update(_VU).where(_VU.status == 2).values(status=0)
|
||||
)
|
||||
await db.commit()
|
||||
return ApiResponse(message=f"已登出 {count} 个用户", data={"count": count})
|
||||
@@ -1,72 +0,0 @@
|
||||
"""
|
||||
互动管理 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")
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
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=["控制台"])
|
||||
@@ -1,126 +0,0 @@
|
||||
"""
|
||||
系统配置 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()]
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
"""
|
||||
虚拟用户管理 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
|
||||
Regular → Executable
+1
-6
@@ -1,6 +1 @@
|
||||
"""
|
||||
核心模块初始化
|
||||
"""
|
||||
from .config import settings
|
||||
|
||||
__all__ = ["settings"]
|
||||
# app.core package
|
||||
|
||||
Regular → Executable
+36
-66
@@ -1,76 +1,46 @@
|
||||
"""
|
||||
系统配置模块
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
"""系统配置"""
|
||||
import os
|
||||
from urllib.parse import quote_plus
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
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
|
||||
|
||||
# 数据库
|
||||
DB_HOST: str = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT: int = int(os.getenv("DB_PORT", "3306"))
|
||||
DB_USER: str = os.getenv("DB_USER", "aivirtual")
|
||||
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "AiVirtual2024")
|
||||
DB_NAME: str = os.getenv("DB_NAME", "ai_virtual_news")
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
|
||||
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
||||
|
||||
# 安全
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-prod")
|
||||
AES_KEY: str = os.getenv("AES_KEY", "your-aes-key-32-chars-change-now!")
|
||||
|
||||
# 新闻平台
|
||||
NEWS_PLATFORM_BASE_URL: str = os.getenv(
|
||||
"NEWS_PLATFORM_BASE_URL", "http://192.168.1.200:63120"
|
||||
)
|
||||
|
||||
# 日志目录
|
||||
LOG_DIR: str = "/app/logs"
|
||||
|
||||
@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"
|
||||
|
||||
def DATABASE_URL(self) -> str:
|
||||
# 对密码做 URL 编码,防止 @ # ! 等特殊字符破坏连接字符串
|
||||
pwd = quote_plus(self.DB_PASSWORD)
|
||||
return f"mysql+aiomysql://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
|
||||
|
||||
@property
|
||||
def SYNC_DATABASE_URL(self) -> str:
|
||||
pwd = quote_plus(self.DB_PASSWORD)
|
||||
return f"mysql+pymysql://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# 创建全局配置实例
|
||||
settings = Settings()
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
"""数据库连接管理"""
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import settings
|
||||
from app.core.logger import logger
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""获取数据库会话"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def wait_for_db(max_retries: int = 30, interval: int = 2):
|
||||
"""等待 MySQL 就绪,最多重试 max_retries 次"""
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(__import__("sqlalchemy").text("SELECT 1"))
|
||||
logger.info(f"✅ 数据库连接成功(第 {attempt} 次尝试)")
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt == max_retries:
|
||||
logger.error(f"数据库连接失败,已重试 {max_retries} 次: {e}")
|
||||
raise
|
||||
logger.warning(f"数据库未就绪,{interval}s 后重试({attempt}/{max_retries}): {e}")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""初始化数据库 - 等待 MySQL 就绪并注册所有模型"""
|
||||
try:
|
||||
# 等待 MySQL 容器真正就绪
|
||||
await wait_for_db(max_retries=30, interval=2)
|
||||
|
||||
# 导入所有模型类,确保 SQLAlchemy ORM 元数据注册
|
||||
from app.models import (
|
||||
VirtualUser, UserPersonality, InteractionRecord,
|
||||
TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||
)
|
||||
logger.info("✅ 数据库模型注册成功")
|
||||
logger.info("✅ 数据库初始化完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"数据库初始化失败: {e}")
|
||||
raise
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
"""日志配置"""
|
||||
import sys
|
||||
import os
|
||||
from loguru import logger
|
||||
|
||||
LOG_DIR = os.getenv("LOG_DIR", "./logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
# 移除默认处理器
|
||||
logger.remove()
|
||||
|
||||
# 控制台输出
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level="INFO",
|
||||
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
||||
)
|
||||
|
||||
# 通用日志文件
|
||||
logger.add(
|
||||
f"{LOG_DIR}/app_{{time:YYYY-MM-DD}}.log",
|
||||
rotation="00:00",
|
||||
retention="30 days",
|
||||
level="INFO",
|
||||
encoding="utf-8",
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
|
||||
)
|
||||
|
||||
# 错误日志文件
|
||||
logger.add(
|
||||
f"{LOG_DIR}/error_{{time:YYYY-MM-DD}}.log",
|
||||
rotation="00:00",
|
||||
retention="30 days",
|
||||
level="ERROR",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# AI调用日志
|
||||
logger.add(
|
||||
f"{LOG_DIR}/ai_{{time:YYYY-MM-DD}}.log",
|
||||
rotation="00:00",
|
||||
retention="30 days",
|
||||
level="INFO",
|
||||
encoding="utf-8",
|
||||
filter=lambda record: "ai_call" in record["extra"],
|
||||
)
|
||||
|
||||
__all__ = ["logger"]
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
"""Redis缓存客户端"""
|
||||
import json
|
||||
import redis.asyncio as aioredis
|
||||
from app.core.config import settings
|
||||
from app.core.logger import logger
|
||||
|
||||
_redis_client = None
|
||||
|
||||
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = aioredis.from_url(
|
||||
f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}",
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
return _redis_client
|
||||
|
||||
|
||||
# Session键前缀
|
||||
SESSION_PREFIX = "session:"
|
||||
LOCK_PREFIX = "lock:"
|
||||
RATE_PREFIX = "rate:"
|
||||
|
||||
|
||||
async def set_session(user_id: int, session_data: dict, expire: int = 86400):
|
||||
"""存储用户会话"""
|
||||
r = await get_redis()
|
||||
key = f"{SESSION_PREFIX}{user_id}"
|
||||
await r.setex(key, expire, json.dumps(session_data, ensure_ascii=False))
|
||||
|
||||
|
||||
async def get_session(user_id: int) -> dict | None:
|
||||
"""获取用户会话"""
|
||||
r = await get_redis()
|
||||
key = f"{SESSION_PREFIX}{user_id}"
|
||||
data = await r.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
|
||||
|
||||
async def delete_session(user_id: int):
|
||||
"""删除用户会话"""
|
||||
r = await get_redis()
|
||||
key = f"{SESSION_PREFIX}{user_id}"
|
||||
await r.delete(key)
|
||||
|
||||
|
||||
async def acquire_lock(name: str, expire: int = 60) -> bool:
|
||||
"""获取分布式锁"""
|
||||
r = await get_redis()
|
||||
key = f"{LOCK_PREFIX}{name}"
|
||||
result = await r.set(key, "1", nx=True, ex=expire)
|
||||
return result is True
|
||||
|
||||
|
||||
async def release_lock(name: str):
|
||||
"""释放分布式锁"""
|
||||
r = await get_redis()
|
||||
key = f"{LOCK_PREFIX}{name}"
|
||||
await r.delete(key)
|
||||
|
||||
|
||||
async def incr_rate(key: str, expire: int = 86400) -> int:
|
||||
"""限流计数"""
|
||||
r = await get_redis()
|
||||
rate_key = f"{RATE_PREFIX}{key}"
|
||||
count = await r.incr(rate_key)
|
||||
if count == 1:
|
||||
await r.expire(rate_key, expire)
|
||||
return count
|
||||
|
||||
|
||||
async def get_counter(key: str) -> int:
|
||||
"""获取计数"""
|
||||
r = await get_redis()
|
||||
rate_key = f"{RATE_PREFIX}{key}"
|
||||
val = await r.get(rate_key)
|
||||
return int(val) if val else 0
|
||||
Regular → Executable
+69
-60
@@ -1,95 +1,104 @@
|
||||
"""
|
||||
FastAPI 应用主文件
|
||||
AI虚拟用户新闻互动系统 - 后端主入口
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from loguru import logger as loguru_logger
|
||||
from fastapi.responses import JSONResponse
|
||||
import re as _re
|
||||
|
||||
# 修复 Pydantic v2 把 naive datetime 序列化为 +00:00 的问题
|
||||
# 数据库存的是北京时间,应标记为 +08:00
|
||||
from fastapi.responses import Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
import json as _json
|
||||
|
||||
class _CNJSONResponse(JSONResponse):
|
||||
def render(self, content) -> bytes:
|
||||
text = _json.dumps(content, ensure_ascii=False, allow_nan=False)
|
||||
# 把 +00:00 替换为 +08:00
|
||||
text = _re.sub(r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\+00:00', r'\1+08:00', text)
|
||||
return text.encode('utf-8')
|
||||
|
||||
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
|
||||
from app.core.database import init_db
|
||||
from app.core.logger import logger
|
||||
from app.api import router
|
||||
from app.services.scheduler import scheduler_service
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
# 自定义 datetime 序列化:数据库存的是北京时间,输出时标记为 +08:00
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
import json as _json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class ChinaDatetimeEncoder(_json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, datetime.datetime):
|
||||
# 标记为 +08:00 时区
|
||||
return obj.strftime("%Y-%m-%dT%H:%M:%S+08:00")
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 启动时执行
|
||||
logger.info("Starting application...")
|
||||
|
||||
logger.info("🚀 AI虚拟用户新闻互动系统启动中...")
|
||||
# 初始化数据库
|
||||
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")
|
||||
|
||||
await init_db()
|
||||
# 启动调度器
|
||||
await scheduler_service.start()
|
||||
logger.info("✅ 系统启动完成")
|
||||
yield
|
||||
|
||||
# 关闭时执行
|
||||
logger.info("Shutting down application...")
|
||||
scheduler_service.stop()
|
||||
# 关闭调度器
|
||||
await scheduler_service.stop()
|
||||
logger.info("🛑 系统已关闭")
|
||||
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="会会虚拟用户 AI 互动系统后端 API",
|
||||
lifespan=lifespan
|
||||
import datetime as _dt
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
class _DatetimeJSONResponse(JSONResponse):
|
||||
def render(self, content) -> bytes:
|
||||
import json
|
||||
def _default(obj):
|
||||
if isinstance(obj, _dt.datetime):
|
||||
return obj.strftime("%Y-%m-%dT%H:%M:%S+08:00")
|
||||
raise TypeError(repr(obj))
|
||||
return json.dumps(content, ensure_ascii=False, default=_default).encode('utf-8')
|
||||
|
||||
app = FastAPI(default_response_class=_CNJSONResponse,
|
||||
|
||||
title="AI虚拟用户新闻互动系统",
|
||||
description="基于AI驱动的虚拟用户新闻互动自动化平台",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
)
|
||||
|
||||
# 配置 CORS
|
||||
# CORS配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 生产环境应该配置具体的域名
|
||||
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.include_router(router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"scheduler_running": scheduler_service.is_running
|
||||
}
|
||||
return {"status": "ok", "service": "ai-virtual-news-backend"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=settings.DEBUG
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
logger.error(f"全局异常: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"code": 500, "message": f"服务器内部错误: {str(exc)}"},
|
||||
)
|
||||
|
||||
Regular → Executable
+131
-24
@@ -1,25 +1,132 @@
|
||||
"""
|
||||
数据库模型初始化
|
||||
"""
|
||||
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
|
||||
"""SQLAlchemy ORM 模型"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
BigInteger, Integer, SmallInteger, String, Text, DateTime,
|
||||
Boolean, Float, Date, JSON, func
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"engine",
|
||||
"get_db",
|
||||
"SessionLocal",
|
||||
"VirtualUser",
|
||||
"VirtualUserPersona",
|
||||
"InteractionRecord",
|
||||
"InteractionType",
|
||||
"TokenUsage",
|
||||
"SystemConfig",
|
||||
"AIModelConfig",
|
||||
"NewsCache",
|
||||
]
|
||||
|
||||
class VirtualUser(Base):
|
||||
__tablename__ = "virtual_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
nickname: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
account: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
|
||||
password_enc: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(512))
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
activity_level: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||
daily_comment_limit: Mapped[int] = mapped_column(Integer, default=10)
|
||||
daily_like_limit: Mapped[int] = mapped_column(Integer, default=30)
|
||||
today_comment_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
today_like_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
total_interactions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
session_token: Mapped[str | None] = mapped_column(Text)
|
||||
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
last_interact_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
real_name: Mapped[str | None] = mapped_column(String(64)) # 真实姓名(从平台同步)
|
||||
sex: Mapped[int] = mapped_column(SmallInteger, default=0) # 性别 0未知 1男 2女
|
||||
platform_uid: Mapped[str | None] = mapped_column(String(64)) # 平台用户ID
|
||||
remark: Mapped[str | None] = mapped_column(String(256))
|
||||
is_enabled: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class UserPersonality(Base):
|
||||
__tablename__ = "user_personalities"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True)
|
||||
character_type: Mapped[str | None] = mapped_column(String(32))
|
||||
language_style: Mapped[str | None] = mapped_column(String(32))
|
||||
interest_tags: Mapped[dict | None] = mapped_column(JSON)
|
||||
interact_tendency: Mapped[str | None] = mapped_column(String(32))
|
||||
word_count_min: Mapped[int] = mapped_column(Integer, default=20)
|
||||
word_count_max: Mapped[int] = mapped_column(Integer, default=100)
|
||||
personality_desc: Mapped[str | None] = mapped_column(Text)
|
||||
comment_style_prompt: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class InteractionRecord(Base):
|
||||
__tablename__ = "interaction_records"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
user_nickname: Mapped[str | None] = mapped_column(String(64))
|
||||
user_account: Mapped[str | None] = mapped_column(String(128))
|
||||
article_id: Mapped[str | None] = mapped_column(String(64))
|
||||
article_title: Mapped[str | None] = mapped_column(String(256))
|
||||
interact_type: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||
content: Mapped[str | None] = mapped_column(Text)
|
||||
platform_record_id: Mapped[str | None] = mapped_column(String(64)) # 平台返回的记录ID(用于取消互动)
|
||||
parent_comment_id: Mapped[str | None] = mapped_column(String(64))
|
||||
session_id: Mapped[str | None] = mapped_column(String(128))
|
||||
token_consumed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
error_msg: Mapped[str | None] = mapped_column(String(512))
|
||||
retry_count: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
executed_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class TokenStat(Base):
|
||||
__tablename__ = "token_stats"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
stat_date: Mapped[datetime] = mapped_column(Date, nullable=False, unique=True)
|
||||
model_name: Mapped[str | None] = mapped_column(String(64))
|
||||
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
prompt_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
completion_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
call_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class AIModelConfig(Base):
|
||||
__tablename__ = "ai_model_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
model_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
api_base_url: Mapped[str | None] = mapped_column(String(256))
|
||||
api_key_enc: Mapped[str | None] = mapped_column(String(512))
|
||||
model_version: Mapped[str | None] = mapped_column(String(64))
|
||||
temperature: Mapped[float] = mapped_column(Float, default=0.7)
|
||||
max_tokens: Mapped[int] = mapped_column(Integer, default=1000)
|
||||
timeout_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
||||
is_default: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
is_enabled: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "system_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
config_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
config_value: Mapped[str | None] = mapped_column(Text)
|
||||
config_type: Mapped[str] = mapped_column(String(16), default="string")
|
||||
description: Mapped[str | None] = mapped_column(String(256))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class LoginLog(Base):
|
||||
__tablename__ = "login_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
user_account: Mapped[str | None] = mapped_column(String(128))
|
||||
action: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
session_id: Mapped[str | None] = mapped_column(String(128))
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64))
|
||||
error_msg: Mapped[str | None] = mapped_column(String(512))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
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}')>"
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
# models package - re-export all models
|
||||
from app.models import (
|
||||
VirtualUser, UserPersonality, InteractionRecord,
|
||||
TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||
)
|
||||
|
||||
# Aliases for import compatibility
|
||||
virtual_user = VirtualUser
|
||||
personality = UserPersonality
|
||||
interaction = InteractionRecord
|
||||
token_stat = TokenStat
|
||||
ai_model = AIModelConfig
|
||||
system_config = SystemConfig
|
||||
login_log = LoginLog
|
||||
|
||||
__all__ = [
|
||||
"VirtualUser", "UserPersonality", "InteractionRecord",
|
||||
"TokenStat", "AIModelConfig", "SystemConfig", "LoginLog",
|
||||
]
|
||||
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
数据库基础配置
|
||||
"""
|
||||
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")
|
||||
@@ -1,68 +0,0 @@
|
||||
"""
|
||||
互动记录模型
|
||||
"""
|
||||
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}')>"
|
||||
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
新闻缓存模型
|
||||
"""
|
||||
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]}...')>"
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
系统配置模型
|
||||
"""
|
||||
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}')>"
|
||||
@@ -1,36 +0,0 @@
|
||||
"""
|
||||
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}')>"
|
||||
@@ -1,91 +0,0 @@
|
||||
"""
|
||||
虚拟用户模型
|
||||
"""
|
||||
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}')>"
|
||||
Regular → Executable
+228
-52
@@ -1,53 +1,229 @@
|
||||
"""
|
||||
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
|
||||
"""Pydantic数据模型 - 请求/响应模式"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import timezone, timedelta
|
||||
|
||||
__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",
|
||||
]
|
||||
_CST = timedelta(hours=8)
|
||||
|
||||
def _fmt_dt(dt):
|
||||
if dt is None: return None
|
||||
if hasattr(dt, "strftime"): return dt.strftime("%Y-%m-%dT%H:%M:%S+08:00")
|
||||
return dt
|
||||
|
||||
|
||||
|
||||
# ===== 通用响应 =====
|
||||
class ApiResponse(BaseModel):
|
||||
code: int = 200
|
||||
message: str = "success"
|
||||
data: Any = None
|
||||
|
||||
|
||||
class PageResult(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
items: List[Any]
|
||||
|
||||
|
||||
# ===== 虚拟用户 =====
|
||||
class UserCreateRequest(BaseModel):
|
||||
# 必填
|
||||
account: str = Field(..., min_length=1, max_length=128, description="新闻平台账号(必填)")
|
||||
password: str = Field(..., min_length=6, max_length=64, description="登录密码(必填)")
|
||||
# 选填
|
||||
nickname: Optional[str] = Field(None, max_length=64, description="昵称(选填,为空自动生成)")
|
||||
avatar_url: Optional[str] = None
|
||||
activity_level: int = Field(default=1, ge=0, le=2)
|
||||
daily_comment_limit: int = Field(default=10, ge=1, le=100)
|
||||
daily_like_limit: int = Field(default=30, ge=1, le=200)
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
nickname: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
password: Optional[str] = Field(None, min_length=6, max_length=64)
|
||||
avatar_url: Optional[str] = None
|
||||
real_name: Optional[str] = None
|
||||
sex: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
activity_level: Optional[int] = Field(None, ge=0, le=2)
|
||||
daily_comment_limit: Optional[int] = Field(None, ge=1, le=100)
|
||||
daily_like_limit: Optional[int] = Field(None, ge=1, le=200)
|
||||
remark: Optional[str] = None
|
||||
is_enabled: Optional[int] = None
|
||||
sync_to_platform: bool = False
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
account: str
|
||||
avatar_url: Optional[str]
|
||||
real_name: Optional[str] = None
|
||||
sex: int = 0
|
||||
platform_uid: Optional[str] = None
|
||||
status: int
|
||||
status_label: str
|
||||
activity_level: int
|
||||
activity_label: str
|
||||
daily_comment_limit: int
|
||||
daily_like_limit: int
|
||||
today_comment_count: int
|
||||
today_like_count: int
|
||||
total_interactions: int
|
||||
last_login_at: Optional[datetime]
|
||||
last_interact_at: Optional[datetime]
|
||||
remark: Optional[str]
|
||||
is_enabled: int
|
||||
created_at: datetime
|
||||
personality: Optional[dict] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserBatchRequest(BaseModel):
|
||||
user_ids: List[int]
|
||||
action: str # enable/disable/logout/delete
|
||||
|
||||
|
||||
# ===== 人格 =====
|
||||
class PersonalityUpdateRequest(BaseModel):
|
||||
character_type: Optional[str] = None
|
||||
language_style: Optional[str] = None
|
||||
interest_tags: Optional[List[str]] = None
|
||||
interact_tendency: Optional[str] = None
|
||||
word_count_min: Optional[int] = Field(None, ge=10, le=500)
|
||||
word_count_max: Optional[int] = Field(None, ge=10, le=1000)
|
||||
personality_desc: Optional[str] = None
|
||||
|
||||
|
||||
class PersonalityResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
character_type: Optional[str]
|
||||
language_style: Optional[str]
|
||||
interest_tags: Optional[List[str]]
|
||||
interact_tendency: Optional[str]
|
||||
word_count_min: int
|
||||
word_count_max: int
|
||||
personality_desc: Optional[str]
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===== 互动记录 =====
|
||||
class InteractionQueryParams(BaseModel):
|
||||
page: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=20, ge=1, le=100)
|
||||
user_id: Optional[int] = None
|
||||
interact_type: Optional[str] = None
|
||||
status: Optional[int] = None
|
||||
start_date: Optional[str] = None
|
||||
end_date: Optional[str] = None
|
||||
keyword: Optional[str] = None
|
||||
|
||||
|
||||
class InteractionResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
user_nickname: Optional[str]
|
||||
user_account: Optional[str]
|
||||
article_id: Optional[str]
|
||||
article_title: Optional[str]
|
||||
interact_type: str
|
||||
interact_type_label: str
|
||||
content: Optional[str]
|
||||
token_consumed: int
|
||||
status: int
|
||||
status_label: str
|
||||
error_msg: Optional[str]
|
||||
retry_count: int
|
||||
executed_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===== AI模型配置 =====
|
||||
class AIModelCreateRequest(BaseModel):
|
||||
model_name: str = Field(..., min_length=1, max_length=64)
|
||||
provider: str = Field(..., pattern="^(openai|zhipu|wenxin|qianwen|local)$")
|
||||
api_base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
model_version: Optional[str] = None
|
||||
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
||||
max_tokens: int = Field(default=1000, ge=1, le=32000)
|
||||
timeout_seconds: int = Field(default=30, ge=5, le=300)
|
||||
is_default: int = Field(default=0, ge=0, le=1)
|
||||
|
||||
|
||||
class AIModelUpdateRequest(BaseModel):
|
||||
model_name: Optional[str] = None
|
||||
api_base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
model_version: Optional[str] = None
|
||||
temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
|
||||
max_tokens: Optional[int] = Field(None, ge=1, le=32000)
|
||||
timeout_seconds: Optional[int] = Field(None, ge=5, le=300)
|
||||
is_default: Optional[int] = None
|
||||
is_enabled: Optional[int] = None
|
||||
|
||||
|
||||
class AIModelResponse(BaseModel):
|
||||
id: int
|
||||
model_name: str
|
||||
provider: str
|
||||
api_base_url: Optional[str]
|
||||
has_api_key: bool
|
||||
model_version: Optional[str]
|
||||
temperature: float
|
||||
max_tokens: int
|
||||
timeout_seconds: int
|
||||
is_default: int
|
||||
is_enabled: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AIModelTestRequest(BaseModel):
|
||||
model_id: int
|
||||
test_prompt: str = "你好,请简单介绍一下自己。"
|
||||
|
||||
|
||||
# ===== 系统配置 =====
|
||||
class SystemConfigUpdateRequest(BaseModel):
|
||||
configs: dict
|
||||
|
||||
|
||||
# ===== 数据统计 =====
|
||||
class DashboardResponse(BaseModel):
|
||||
user_stats: dict
|
||||
today_interactions: dict
|
||||
monthly_stats: dict
|
||||
token_stats: dict
|
||||
system_status: dict
|
||||
online_users: int
|
||||
|
||||
|
||||
# ===== 调度配置 =====
|
||||
class SchedulerConfigRequest(BaseModel):
|
||||
interact_time_start: Optional[str] = None
|
||||
interact_time_end: Optional[str] = None
|
||||
interact_interval_min: Optional[int] = None
|
||||
interact_interval_max: Optional[int] = None
|
||||
max_concurrent_users: Optional[int] = None
|
||||
daily_token_limit: Optional[int] = None
|
||||
comment_probability: Optional[float] = None
|
||||
reply_probability: Optional[float] = None
|
||||
like_probability: Optional[float] = None
|
||||
collect_probability: Optional[float] = None
|
||||
forward_probability: Optional[float] = None
|
||||
scheduler_enabled: Optional[bool] = None
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""
|
||||
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]
|
||||
@@ -1,55 +0,0 @@
|
||||
"""
|
||||
控制台仪表盘相关 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)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""
|
||||
互动记录相关 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="是否强制执行(忽略限额)")
|
||||
@@ -1,60 +0,0 @@
|
||||
"""
|
||||
系统配置相关 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)
|
||||
@@ -1,54 +0,0 @@
|
||||
"""
|
||||
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]
|
||||
@@ -1,86 +0,0 @@
|
||||
"""
|
||||
虚拟用户相关 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 人格描述")
|
||||
Regular → Executable
-18
@@ -1,18 +0,0 @@
|
||||
"""
|
||||
服务层模块初始化
|
||||
"""
|
||||
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",
|
||||
]
|
||||
|
||||
Regular → Executable
+239
-282
@@ -1,301 +1,258 @@
|
||||
"""
|
||||
AI 大模型对接服务
|
||||
支持 OpenAI、智谱、百度文心、阿里通义等主流大模型
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
"""AI服务 - 人格生成、内容创作"""
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from typing import Optional
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.models import AIModelConfig, TokenStat
|
||||
from app.utils.crypto import decrypt
|
||||
from app.core.logger import logger
|
||||
from datetime import date
|
||||
|
||||
|
||||
class AIService:
|
||||
"""AI 服务类"""
|
||||
|
||||
def __init__(self):
|
||||
self._client_cache: Dict[str, Any] = {}
|
||||
|
||||
"""AI大模型服务"""
|
||||
|
||||
# 人格候选池
|
||||
CHARACTER_TYPES = ["开朗", "内敛", "毒舌", "温和", "理性", "感性", "幽默", "严谨"]
|
||||
LANGUAGE_STYLES = ["严肃", "幽默", "文艺", "吐槽", "口语化", "学术", "简洁", "丰富"]
|
||||
INTEREST_TAGS_POOL = [
|
||||
"科技", "财经", "娱乐", "体育", "政治", "文化", "教育", "医疗",
|
||||
"汽车", "房产", "旅游", "美食", "军事", "国际", "环保", "农业"
|
||||
]
|
||||
INTERACT_TENDENCIES = ["爱评论", "爱点赞", "爱收藏", "潜水", "爱转发", "爱回复"]
|
||||
|
||||
async def _get_default_model(self, db: AsyncSession) -> Optional[AIModelConfig]:
|
||||
result = await db.execute(
|
||||
select(AIModelConfig).where(
|
||||
AIModelConfig.is_default == 1, AIModelConfig.is_enabled == 1
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _call_api(
|
||||
self, db: AsyncSession, prompt: str, system_prompt: str = None,
|
||||
max_tokens: int = None
|
||||
) -> tuple[str, int]:
|
||||
"""调用AI接口,返回(内容, token数)"""
|
||||
model = await self._get_default_model(db)
|
||||
if not model:
|
||||
# 无模型配置时返回随机预设
|
||||
return "", 0
|
||||
|
||||
api_key = decrypt(model.api_key_enc) if model.api_key_enc else ""
|
||||
base_url = model.api_base_url or "https://api.openai.com/v1"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
payload = {
|
||||
"model": model.model_version or "gpt-3.5-turbo",
|
||||
"messages": messages,
|
||||
"temperature": model.temperature,
|
||||
"max_tokens": max_tokens or model.max_tokens,
|
||||
}
|
||||
|
||||
import asyncio as _asyncio
|
||||
last_err = None
|
||||
for attempt in range(3): # 最多重试3次
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=model.timeout_seconds) as client:
|
||||
resp = await client.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
# 429 限流:等待后重试
|
||||
if resp.status_code == 429:
|
||||
wait = 30 * (attempt + 1) # 30s, 60s, 90s
|
||||
logger.warning(f"AI接口限流(429),{wait}s后重试({attempt+1}/3)")
|
||||
await _asyncio.sleep(wait)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
text = data["choices"][0]["message"]["content"].strip()
|
||||
tokens = data.get("usage", {}).get("total_tokens", 0)
|
||||
await self._record_token_usage(db, tokens, data.get("usage", {}), model.model_name)
|
||||
logger.bind(ai_call=True).info(
|
||||
f"AI调用成功 model={model.model_name} tokens={tokens}"
|
||||
)
|
||||
return text, tokens
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 2:
|
||||
await _asyncio.sleep(5 * (attempt + 1))
|
||||
logger.error(f"AI调用失败(已重试3次): {last_err}")
|
||||
return "", 0
|
||||
|
||||
async def generate_personality(self, nickname: str, account: str) -> dict:
|
||||
"""生成用户人格(含fallback随机生成)"""
|
||||
# 如无AI配置,使用随机生成
|
||||
from app.core.database import AsyncSessionLocal
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
model = await self._get_default_model(db)
|
||||
if not model:
|
||||
return self._random_personality()
|
||||
|
||||
prompt = f"""请为以下虚拟新闻读者生成一个独特的人格档案,要求真实自然、贴合中国用户特征。
|
||||
用户昵称:{nickname}
|
||||
|
||||
请严格以JSON格式返回,不要有其他内容:
|
||||
{{
|
||||
"character_type": "从[开朗/内敛/毒舌/温和/理性/感性/幽默/严谨]中选一个",
|
||||
"language_style": "从[严肃/幽默/文艺/吐槽/口语化/学术/简洁/丰富]中选一个",
|
||||
"interest_tags": ["兴趣1", "兴趣2", "兴趣3"],
|
||||
"interact_tendency": "从[爱评论/爱点赞/爱收藏/潜水/爱转发/爱回复]中选一个",
|
||||
"word_count_min": 最少字数(10-50整数),
|
||||
"word_count_max": 最多字数(50-200整数),
|
||||
"personality_desc": "一句话描述此人的性格特点(30字以内)"
|
||||
}}"""
|
||||
content, _ = await self._call_api(db, prompt, max_tokens=300)
|
||||
if content:
|
||||
try:
|
||||
# 提取JSON
|
||||
json_match = re.search(r'\{.*\}', content, re.DOTALL)
|
||||
if json_match:
|
||||
return json.loads(json_match.group())
|
||||
except Exception:
|
||||
pass
|
||||
return self._random_personality()
|
||||
except Exception as e:
|
||||
logger.error(f"人格生成失败: {e}")
|
||||
return self._random_personality()
|
||||
|
||||
def _random_personality(self) -> dict:
|
||||
"""随机生成人格(无AI时的备用方案)"""
|
||||
interests = random.sample(self.INTEREST_TAGS_POOL, random.randint(2, 4))
|
||||
char = random.choice(self.CHARACTER_TYPES)
|
||||
style = random.choice(self.LANGUAGE_STYLES)
|
||||
tendency = random.choice(self.INTERACT_TENDENCIES)
|
||||
w_min = random.randint(15, 40)
|
||||
w_max = random.randint(60, 150)
|
||||
return {
|
||||
"character_type": char,
|
||||
"language_style": style,
|
||||
"interest_tags": interests,
|
||||
"interact_tendency": tendency,
|
||||
"word_count_min": w_min,
|
||||
"word_count_max": w_max,
|
||||
"personality_desc": f"一个{char}性格、{tendency}的新闻读者",
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
self, db: AsyncSession, article_title: str, article_content: str,
|
||||
personality_prompt: str, word_min: int = 20, word_max: int = 80
|
||||
) -> tuple[str, int]:
|
||||
"""生成文章评论"""
|
||||
system_prompt = f"""你是一名真实的社区用户,正在阅读新闻后发表评论。{personality_prompt}
|
||||
|
||||
重要规则:
|
||||
- 评论必须积极正面、文明友善,绝对不包含任何政治敏感、色情、暴力、侮辱、歧视内容
|
||||
- 不要提及具体政治人物、党派、政策批评、社会矛盾等敏感话题
|
||||
- 内容围绕文章本身展开,表达个人感受、分享观点、提出建设性问题
|
||||
- 语言朴实自然,像普通网友留言,不夸张不煽情"""
|
||||
|
||||
prompt = f"""请根据以下新闻文章写一条评论。
|
||||
|
||||
文章标题:{article_title}
|
||||
文章摘要:{article_content[:200] if article_content else '(无摘要)'}
|
||||
|
||||
要求:
|
||||
1. 评论字数 {word_min}~{word_max} 字
|
||||
2. 内容积极正面,贴近文章主题
|
||||
3. 语气自然真实,符合普通读者口吻
|
||||
4. 必须是完整的句子,不能被截断,以句号/感叹号/问号结尾
|
||||
5. 只输出评论正文,不要加任何前缀或解释
|
||||
|
||||
评论:"""
|
||||
return await self._call_api(db, prompt, system_prompt, max_tokens=500)
|
||||
|
||||
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"""你是一位虚拟用户,请根据以下要求写一条简短的评论:
|
||||
self, db: AsyncSession, article_title: str, parent_comment: str,
|
||||
personality_prompt: str, word_min: int = 15, word_max: int = 60
|
||||
) -> tuple[str, int]:
|
||||
"""生成回复"""
|
||||
system_prompt = f"""你是一名真实的社区用户。{personality_prompt}
|
||||
|
||||
写作风格:{writing_style}
|
||||
"""
|
||||
|
||||
if persona_description:
|
||||
base_prompt += f"\n人格特征:{persona_description}\n"
|
||||
|
||||
base_prompt += f"""
|
||||
新闻内容:
|
||||
{news_content[:1000]} # 限制长度
|
||||
重要规则:回复必须积极正面、文明友善,不含任何敏感违规内容。"""
|
||||
prompt = f"""文章:{article_title}
|
||||
原评论:{parent_comment}
|
||||
|
||||
请写一条 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"""你是一位虚拟用户,请根据以下要求回复另一条评论:
|
||||
请对上面的评论写一条友善自然的回复,{word_min}~{word_max}字,直接输出回复内容。"""
|
||||
return await self._call_api(db, prompt, system_prompt, max_tokens=150)
|
||||
|
||||
写作风格:{writing_style}
|
||||
"""
|
||||
|
||||
if persona_description:
|
||||
base_prompt += f"\n人格特征:{persona_description}\n"
|
||||
|
||||
base_prompt += f"""
|
||||
新闻内容:
|
||||
{news_content[:500]}
|
||||
async def test_model(self, db: AsyncSession, model_id: int, test_prompt: str) -> dict:
|
||||
"""测试模型可用性"""
|
||||
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
return {"success": False, "error": "模型不存在"}
|
||||
|
||||
原评论:
|
||||
{original_comment}
|
||||
api_key = decrypt(model.api_key_enc) if model.api_key_enc else ""
|
||||
base_url = model.api_base_url or "https://api.openai.com/v1"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
请写一条 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,
|
||||
payload = {
|
||||
"model": model.model_version or "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": test_prompt}],
|
||||
"max_tokens": 200,
|
||||
}
|
||||
try:
|
||||
import time
|
||||
start = time.time()
|
||||
async with httpx.AsyncClient(timeout=model.timeout_seconds) as client:
|
||||
resp = await client.post(f"{base_url}/chat/completions", headers=headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
elapsed = round(time.time() - start, 2)
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
tokens = data.get("usage", {}).get("total_tokens", 0)
|
||||
return {
|
||||
"success": True, "content": content,
|
||||
"tokens": tokens, "elapsed_seconds": elapsed,
|
||||
}
|
||||
|
||||
provider = model_config.get("provider", "").lower()
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def _record_token_usage(
|
||||
self, db: AsyncSession, total: int, usage: dict, model_name: str
|
||||
):
|
||||
"""记录Token消耗"""
|
||||
today = date.today()
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
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)
|
||||
existing = await db.execute(
|
||||
select(TokenStat).where(TokenStat.stat_date == today)
|
||||
)
|
||||
stat = existing.scalar_one_or_none()
|
||||
if stat:
|
||||
stat.total_tokens += total
|
||||
stat.prompt_tokens += usage.get("prompt_tokens", 0)
|
||||
stat.completion_tokens += usage.get("completion_tokens", 0)
|
||||
stat.call_count += 1
|
||||
else:
|
||||
logger.error(f"Unsupported AI provider: {provider}")
|
||||
return None
|
||||
stat = TokenStat(
|
||||
stat_date=today,
|
||||
model_name=model_name,
|
||||
total_tokens=total,
|
||||
prompt_tokens=usage.get("prompt_tokens", 0),
|
||||
completion_tokens=usage.get("completion_tokens", 0),
|
||||
call_count=1,
|
||||
)
|
||||
db.add(stat)
|
||||
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"
|
||||
}
|
||||
logger.error(f"记录Token消耗失败: {e}")
|
||||
|
||||
|
||||
# 创建全局服务实例
|
||||
ai_service = AIService()
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
"""
|
||||
会会接口对接服务
|
||||
基于 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()
|
||||
@@ -1,408 +0,0 @@
|
||||
"""
|
||||
互动执行服务
|
||||
"""
|
||||
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)
|
||||
Executable
+1017
File diff suppressed because it is too large
Load Diff
Executable
+514
@@ -0,0 +1,514 @@
|
||||
"""调度服务 - 定时自动互动、会话校验"""
|
||||
import random
|
||||
import asyncio
|
||||
from datetime import datetime, date
|
||||
from typing import Optional
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy import select, update, func
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.logger import logger
|
||||
from app.models import VirtualUser, UserPersonality, InteractionRecord, SystemConfig
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
def __init__(self):
|
||||
self.scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||
self._running = False
|
||||
|
||||
async def run_once_now(self, db=None):
|
||||
"""立即执行一次互动,不受时间段限制"""
|
||||
from sqlalchemy import select
|
||||
from app.core.database import AsyncSessionLocal
|
||||
logger.info("⚡ 立即触发互动任务")
|
||||
async with AsyncSessionLocal() as session:
|
||||
result_r = await session.execute(
|
||||
select(VirtualUser).where(
|
||||
VirtualUser.status == 2,
|
||||
VirtualUser.is_enabled == 1,
|
||||
)
|
||||
)
|
||||
users = result_r.scalars().all()
|
||||
if not users:
|
||||
return {"message": "没有已登录的用户", "triggered": 0}
|
||||
import random
|
||||
selected = random.sample(users, min(5, len(users)))
|
||||
import asyncio
|
||||
tasks = [self._execute_user_interaction(u.id) for u in selected]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return {"triggered": len(selected), "users": [u.account for u in selected]}
|
||||
|
||||
async def start(self):
|
||||
if self._running:
|
||||
return
|
||||
# 会话校验:每10分钟
|
||||
self.scheduler.add_job(
|
||||
self._check_sessions, IntervalTrigger(minutes=10),
|
||||
id="check_sessions", replace_existing=True
|
||||
)
|
||||
# 互动任务:每5分钟检查一次(内部判断是否在活跃时间段)
|
||||
self.scheduler.add_job(
|
||||
self._run_interactions, IntervalTrigger(minutes=5),
|
||||
id="run_interactions", replace_existing=True
|
||||
)
|
||||
# 每日零点重置计数
|
||||
self.scheduler.add_job(
|
||||
self._daily_reset, "cron", hour=16, minute=0, # 北京时间 00:00 = UTC 16:00
|
||||
id="daily_reset", replace_existing=True
|
||||
)
|
||||
self.scheduler.start()
|
||||
self._running = True
|
||||
logger.info("调度器已启动")
|
||||
# 记录启动时间
|
||||
async with AsyncSessionLocal() as db:
|
||||
await self._set_config(db, "system_start_time", datetime.now().isoformat())
|
||||
|
||||
async def stop(self):
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown(wait=False)
|
||||
self._running = False
|
||||
|
||||
async def _get_config(self, db, key: str, default=None):
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == key))
|
||||
cfg = result.scalar_one_or_none()
|
||||
return cfg.config_value if cfg else default
|
||||
|
||||
async def _set_config(self, db, key: str, value: str):
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == key))
|
||||
cfg = result.scalar_one_or_none()
|
||||
if cfg:
|
||||
cfg.config_value = value
|
||||
else:
|
||||
db.add(SystemConfig(config_key=key, config_value=value))
|
||||
await db.commit()
|
||||
|
||||
async def _check_sessions(self):
|
||||
"""定时校验登录状态"""
|
||||
from app.services.news_service import news_service
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(VirtualUser).where(VirtualUser.status == 2, VirtualUser.is_enabled == 1)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
for user in users:
|
||||
try:
|
||||
valid = await news_service.check_session(db, user)
|
||||
if not valid:
|
||||
logger.warning(f"用户 {user.account} 会话失效,尝试重登")
|
||||
await news_service.login(db, user)
|
||||
except Exception as e:
|
||||
logger.error(f"会话校验异常 {user.account}: {e}")
|
||||
|
||||
async def _run_interactions(self):
|
||||
"""执行互动任务"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 检查调度器开关
|
||||
enabled = await self._get_config(db, "scheduler_enabled", "true")
|
||||
if enabled != "true":
|
||||
return
|
||||
|
||||
# 检查Token限额
|
||||
token_limited = await self._get_config(db, "token_limit_reached", "false")
|
||||
if token_limited == "true":
|
||||
return
|
||||
|
||||
# 检查互动时间段(北京时间 UTC+8)
|
||||
from datetime import timezone, timedelta
|
||||
tz_beijing = timezone(timedelta(hours=8))
|
||||
now_bj = datetime.now(tz_beijing)
|
||||
now_time = now_bj.strftime("%H:%M")
|
||||
start_str = await self._get_config(db, "interact_time_start", "08:00")
|
||||
end_str = await self._get_config(db, "interact_time_end", "22:00")
|
||||
if not (start_str <= now_time <= end_str):
|
||||
logger.debug(f"[调度] 当前北京时间 {now_time} 不在互动时段 {start_str}-{end_str}")
|
||||
return
|
||||
|
||||
# 获取最小互动间隔(秒)
|
||||
min_interval = int(await self._get_config(db, "interact_min_interval", "300"))
|
||||
|
||||
# 获取最大并发
|
||||
max_concurrent = int(await self._get_config(db, "max_concurrent_users", "5"))
|
||||
|
||||
# 获取所有已登录、启用的用户(不加 LIMIT,确保所有用户公平参与)
|
||||
result = await db.execute(
|
||||
select(VirtualUser).where(
|
||||
VirtualUser.status == 2,
|
||||
VirtualUser.is_enabled == 1,
|
||||
)
|
||||
)
|
||||
all_users = result.scalars().all()
|
||||
|
||||
# 没有已登录用户时,尝试登录未登录用户
|
||||
if not all_users:
|
||||
await self._try_login_users(db)
|
||||
return
|
||||
|
||||
# 检查互动间隔:过滤掉最近 min_interval 秒内已互动的用户
|
||||
now_dt = datetime.now()
|
||||
eligible = []
|
||||
for u in all_users:
|
||||
if u.last_interact_at is None:
|
||||
eligible.append(u)
|
||||
else:
|
||||
elapsed = (now_dt - u.last_interact_at).total_seconds()
|
||||
if elapsed >= min_interval:
|
||||
eligible.append(u)
|
||||
|
||||
if not eligible:
|
||||
logger.debug(f"[调度] 所有 {len(all_users)} 个用户在 {min_interval}s 内已互动,跳过本次")
|
||||
return
|
||||
|
||||
# 按最后互动时间升序排序:最久没互动的用户优先
|
||||
eligible.sort(key=lambda u: u.last_interact_at or datetime.min)
|
||||
|
||||
# 从符合条件的用户中随机选取 max_concurrent 个执行(保证公平轮转)
|
||||
batch_size = max_concurrent if max_concurrent > 0 else len(eligible)
|
||||
# 优先选最久未互动的用户(前1/3),其余随机补充
|
||||
priority_size = max(1, batch_size // 3)
|
||||
priority_users = eligible[:priority_size]
|
||||
rest_users = eligible[priority_size:]
|
||||
random.shuffle(rest_users)
|
||||
selected = priority_users + rest_users[:max(0, batch_size - priority_size)]
|
||||
|
||||
# ── 今日文章配额计算 ──────────────────────────────────────
|
||||
# 获取今日文章数量,决定本轮有多少用户应互动今日文章
|
||||
today_count = 0
|
||||
try:
|
||||
from app.services.news_service import news_service as _ns
|
||||
today_count = await _ns.count_today_articles(db, selected[0] if selected else None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 配额规则:每篇今日文章最多吸引 3 个虚拟用户,超出部分走历史
|
||||
today_quota = min(today_count * 3, len(selected))
|
||||
|
||||
logger.info(
|
||||
f"[调度] 共 {len(all_users)} 个用户,{len(eligible)} 个满足间隔,"
|
||||
f"本轮选取 {len(selected)} 个,今日文章 {today_count} 篇,"
|
||||
f"配额 {today_quota} 人互动今日/{len(selected)-today_quota} 人走历史"
|
||||
)
|
||||
|
||||
for i, user in enumerate(selected):
|
||||
# 超出今日配额的用户强制走历史文章
|
||||
force_history = (i >= today_quota)
|
||||
asyncio.create_task(self._execute_user_interaction(user.id, force_history=force_history))
|
||||
|
||||
async def _try_login_users(self, db):
|
||||
"""尝试登录未登录的用户"""
|
||||
from app.services.news_service import news_service
|
||||
result = await db.execute(
|
||||
select(VirtualUser).where(
|
||||
VirtualUser.status.in_([0, 3]),
|
||||
VirtualUser.is_enabled == 1
|
||||
).limit(3)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
for user in users:
|
||||
try:
|
||||
await news_service.login(db, user)
|
||||
await asyncio.sleep(2)
|
||||
except Exception as e:
|
||||
logger.error(f"自动登录失败 {user.account}: {e}")
|
||||
|
||||
async def _execute_user_interaction(self, user_id: int, force_history: bool = False):
|
||||
"""执行单用户互动 - 基于真实接口"""
|
||||
from app.services.news_service import news_service
|
||||
from app.services.ai_service import ai_service
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
user_result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user or user.status != 2:
|
||||
return
|
||||
|
||||
# 检查今日评论限额
|
||||
can_comment = True
|
||||
if user.today_comment_count >= user.daily_comment_limit:
|
||||
can_comment = False
|
||||
logger.info(f'用户 ' + user.account + ' 今日评论已达上限,仍执行点赞/收藏/转发')
|
||||
|
||||
# 获取人格
|
||||
from app.models import UserPersonality
|
||||
p_result = await db.execute(
|
||||
select(UserPersonality).where(UserPersonality.user_id == user_id)
|
||||
)
|
||||
personality = p_result.scalar_one_or_none()
|
||||
interest_tags = personality.interest_tags if personality else []
|
||||
|
||||
# 获取新闻列表(基于接口 GET /news/list)
|
||||
articles = await news_service.get_news_list(
|
||||
db, user, count=5, interest_tags=interest_tags, force_history=force_history
|
||||
)
|
||||
if not articles:
|
||||
# 尝试从 session 获取 org_id 再试一次
|
||||
from app.core.redis_client import get_session as _get_sess
|
||||
sess = await _get_sess(user.id)
|
||||
org_from_sess = sess.get("org_id", "") if sess else ""
|
||||
if org_from_sess:
|
||||
articles = await news_service.get_news_list(
|
||||
db, user, count=5, interest_tags=interest_tags
|
||||
)
|
||||
if not articles:
|
||||
logger.warning(
|
||||
f"用户 {user.account} 获取新闻列表为空 "
|
||||
f"(orgId={await news_service._cfg(db, 'platform_org_id', '')})"
|
||||
)
|
||||
return
|
||||
|
||||
# ── 文章去重 + 热度加权选取 ─────────────────────────────────
|
||||
# 查询今日已互动过的文章(所有类型),避免重复互动同一篇
|
||||
from sqlalchemy import func as _func
|
||||
from datetime import date as _date
|
||||
today_str = datetime.now().date()
|
||||
dup_result = await db.execute(
|
||||
select(
|
||||
InteractionRecord.article_id,
|
||||
InteractionRecord.interact_type,
|
||||
).where(
|
||||
InteractionRecord.user_id == user_id,
|
||||
InteractionRecord.status == 1,
|
||||
_func.date(InteractionRecord.executed_at) == today_str,
|
||||
)
|
||||
)
|
||||
# {article_id: set of interact_types already done today}
|
||||
today_done: dict = {}
|
||||
for r in dup_result.all():
|
||||
today_done.setdefault(r[0], set()).add(r[1])
|
||||
already_commented = {aid for aid, types in today_done.items() if "comment" in types}
|
||||
|
||||
# 按热度加权:commentNum + praiseNum + readNum 越高权重越大
|
||||
# 同时优先未评论过的文章
|
||||
def _article_weight(a):
|
||||
aid = str(a.get("recordId") or a.get("id", ""))
|
||||
base = (
|
||||
int(a.get("commentNum") or 0) * 3 +
|
||||
int(a.get("praiseNum") or 0) * 2 +
|
||||
int(a.get("readNum") or 0)
|
||||
)
|
||||
# 已评论的文章权重大幅降低(但不为0,还可以点赞/收藏)
|
||||
penalty = 0.1 if aid in already_commented else 1.0
|
||||
return max(1, base) * penalty
|
||||
|
||||
weights = [_article_weight(a) for a in articles]
|
||||
article = random.choices(articles, weights=weights, k=1)[0]
|
||||
|
||||
# 判断是否已评论此文章(用于后续逻辑)
|
||||
news_id = str(article.get("recordId") or article.get("id", ""))
|
||||
already_commented_this = "comment" in today_done.get(news_id, set())
|
||||
|
||||
# 接口返回字段: id/newsTitle/content/digest/createUser
|
||||
# 广场接口字段:recordId=新闻实际ID, id=广场记录ID, title=标题
|
||||
news_title = article.get("title") or article.get("newsTitle") or "未知文章"
|
||||
news_content = article.get("content") or article.get("digest") or news_title
|
||||
news_author = str(article.get("createUser") or "")
|
||||
# 从广场数据中顺带获取 orgId
|
||||
article_org_id = str(article.get("orgId") or "")
|
||||
|
||||
if not news_id:
|
||||
return
|
||||
|
||||
# 读取互动概率
|
||||
comment_prob = float(await self._get_config_from_db(db, "comment_probability", "0.4"))
|
||||
reply_prob = float(await self._get_config_from_db(db, "reply_probability", "0.2"))
|
||||
like_prob = float(await self._get_config_from_db(db, "like_probability", "0.6"))
|
||||
collect_prob = float(await self._get_config_from_db(db, "collect_probability", "0.3"))
|
||||
forward_prob = float(await self._get_config_from_db(db, "forward_probability", "0.15"))
|
||||
|
||||
interactions_done = []
|
||||
|
||||
# ① 先记录阅读(每次必做,模拟真实用户打开文章)
|
||||
await news_service.read_news(db, user, news_id)
|
||||
|
||||
# 今日已对此文章做过的互动类型
|
||||
done_on_this = today_done.get(news_id, set())
|
||||
|
||||
# ② 点赞(每篇文章每用户每天只点赞一次)
|
||||
if "like" not in done_on_this and random.random() < like_prob:
|
||||
success, err = await news_service.like_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
|
||||
await self._save_record(db, user, news_id, news_title, "like", None, 0, success, err)
|
||||
if success:
|
||||
interactions_done.append("like")
|
||||
await self._incr_total(db, user_id)
|
||||
|
||||
# ③ 收藏(每篇文章每用户每天只收藏一次)
|
||||
if "collect" not in done_on_this and random.random() < collect_prob:
|
||||
success, err = await news_service.collect_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
|
||||
await self._save_record(db, user, news_id, news_title, "collect", None, 0, success, err)
|
||||
if success:
|
||||
interactions_done.append("collect")
|
||||
|
||||
# ④ 转发(每篇文章每用户每天只转发一次)
|
||||
if "forward" not in done_on_this and random.random() < forward_prob:
|
||||
success, err = await news_service.forward_news(db, user, news_id)
|
||||
await self._save_record(db, user, news_id, news_title, "forward", None, 0, success, err)
|
||||
if success:
|
||||
interactions_done.append("forward")
|
||||
await self._incr_total(db, user_id)
|
||||
|
||||
# ⑤ 评论/回复逻辑(去重:已评论过的文章改为回复他人评论)
|
||||
if can_comment and personality:
|
||||
style_prompt = personality.comment_style_prompt or ""
|
||||
safe_word_max = min(personality.word_count_max, 80)
|
||||
|
||||
if already_commented_this:
|
||||
# 已评论过此文章 → 改为回复其他用户的评论(虚拟用户互动)
|
||||
if random.random() < reply_prob:
|
||||
existing = await news_service.get_comments(db, user, news_id)
|
||||
if existing:
|
||||
# 优先回复虚拟用户的评论(促进互动),过滤掉自己的评论
|
||||
from app.core.redis_client import get_session as _gs
|
||||
my_sess = await _gs(user.id)
|
||||
my_uid = my_sess.get("platform_uid", "") if my_sess else ""
|
||||
others = [c for c in existing
|
||||
if str(c.get("userId") or c.get("createUser") or "") != my_uid]
|
||||
if others:
|
||||
target = random.choice(others)
|
||||
cid = str(target.get("id") or target.get("commentId") or "")
|
||||
parent_content = target.get("content") or ""
|
||||
if cid:
|
||||
reply_text, r_tokens = await ai_service.generate_reply(
|
||||
db, news_title, parent_content,
|
||||
style_prompt,
|
||||
personality.word_count_min,
|
||||
safe_word_max
|
||||
)
|
||||
if reply_text:
|
||||
r_ok, r_err = await news_service.post_reply(
|
||||
db, user, news_id, cid, reply_text
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "reply",
|
||||
reply_text, r_tokens, r_ok, r_err,
|
||||
parent_comment_id=cid
|
||||
)
|
||||
if r_ok:
|
||||
interactions_done.append("reply")
|
||||
logger.info(f"💬 {user.account} 回复了已评论文章的评论(去重逻辑)")
|
||||
else:
|
||||
# 未评论过此文章 → 正常发评论
|
||||
if random.random() < comment_prob:
|
||||
comment_text, tokens = await ai_service.generate_comment(
|
||||
db, news_title, news_content,
|
||||
style_prompt, personality.word_count_min, safe_word_max
|
||||
)
|
||||
if comment_text:
|
||||
success, err = await news_service.post_comment(
|
||||
db, user, news_id, news_title, comment_text,
|
||||
news_author_id=news_author, org_id=article_org_id
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "comment",
|
||||
comment_text, tokens, success, err
|
||||
)
|
||||
if success:
|
||||
interactions_done.append("comment")
|
||||
await db.execute(
|
||||
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||||
today_comment_count=VirtualUser.today_comment_count + 1,
|
||||
total_interactions=VirtualUser.total_interactions + 1,
|
||||
last_interact_at=datetime.now()
|
||||
)
|
||||
)
|
||||
|
||||
# ⑥ 评论成功后,随机回复其他用户的评论(互动链)
|
||||
if random.random() < reply_prob:
|
||||
existing = await news_service.get_comments(db, user, news_id)
|
||||
if existing:
|
||||
from app.core.redis_client import get_session as _gs2
|
||||
my_sess2 = await _gs2(user.id)
|
||||
my_uid2 = my_sess2.get("platform_uid", "") if my_sess2 else ""
|
||||
others2 = [c for c in existing
|
||||
if str(c.get("userId") or c.get("createUser") or "") != my_uid2]
|
||||
if others2:
|
||||
target2 = random.choice(others2)
|
||||
cid2 = str(target2.get("id") or target2.get("commentId") or "")
|
||||
parent_content2 = target2.get("content") or ""
|
||||
if cid2:
|
||||
reply_text2, r_tokens2 = await ai_service.generate_reply(
|
||||
db, news_title, parent_content2,
|
||||
style_prompt,
|
||||
personality.word_count_min,
|
||||
safe_word_max
|
||||
)
|
||||
if reply_text2:
|
||||
r_ok2, r_err2 = await news_service.post_reply(
|
||||
db, user, news_id, cid2, reply_text2
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "reply",
|
||||
reply_text2, r_tokens2, r_ok2, r_err2,
|
||||
parent_comment_id=cid2
|
||||
)
|
||||
if r_ok2:
|
||||
interactions_done.append("reply")
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"👤 {user.account} 互动完成: {interactions_done} [新闻: {news_title[:20]}]")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"用户 {user_id} 互动异常: {e}")
|
||||
|
||||
async def _incr_total(self, db, user_id: int):
|
||||
await db.execute(
|
||||
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||||
total_interactions=VirtualUser.total_interactions + 1,
|
||||
last_interact_at=datetime.now()
|
||||
)
|
||||
)
|
||||
|
||||
async def _save_record(
|
||||
self, db, user: VirtualUser, article_id: str, article_title: str,
|
||||
interact_type: str, content: Optional[str], tokens: int,
|
||||
success: bool, error_msg: str, parent_comment_id: str = None,
|
||||
platform_record_id: str = None
|
||||
):
|
||||
from app.core.redis_client import get_session
|
||||
session = await get_session(user.id)
|
||||
session_id = session.get("session_id") if session else None
|
||||
|
||||
record = InteractionRecord(
|
||||
user_id=user.id,
|
||||
user_nickname=user.nickname,
|
||||
user_account=user.account,
|
||||
article_id=article_id,
|
||||
article_title=article_title,
|
||||
interact_type=interact_type,
|
||||
content=content,
|
||||
parent_comment_id=parent_comment_id,
|
||||
platform_record_id=platform_record_id,
|
||||
session_id=session_id,
|
||||
token_consumed=tokens,
|
||||
status=1 if success else 2,
|
||||
error_msg=error_msg or None,
|
||||
executed_at=datetime.now(),
|
||||
)
|
||||
db.add(record)
|
||||
|
||||
async def _get_config_from_db(self, db, key: str, default: str = "") -> str:
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == key))
|
||||
cfg = result.scalar_one_or_none()
|
||||
return cfg.config_value if cfg else default
|
||||
|
||||
async def _daily_reset(self):
|
||||
"""每日零点重置计数"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(
|
||||
update(VirtualUser).values(
|
||||
today_comment_count=0,
|
||||
today_like_count=0
|
||||
)
|
||||
)
|
||||
# 重置Token限额标志
|
||||
result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.config_key == "token_limit_reached")
|
||||
)
|
||||
cfg = result.scalar_one_or_none()
|
||||
if cfg:
|
||||
cfg.config_value = "false"
|
||||
await db.commit()
|
||||
logger.info("每日计数重置完成")
|
||||
|
||||
|
||||
scheduler_service = SchedulerService()
|
||||
@@ -1,215 +0,0 @@
|
||||
"""
|
||||
定时任务调度服务
|
||||
基于 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()
|
||||
Executable
+251
@@ -0,0 +1,251 @@
|
||||
"""数据统计服务"""
|
||||
from datetime import datetime, date, timedelta, timezone
|
||||
|
||||
def _fmt_dt(dt):
|
||||
"""统一输出 UTC 时间,带时区标识,让前端正确解析为 +8"""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
# 数据库存的是 UTC,补上时区信息
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.isoformat()
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
|
||||
from app.models import VirtualUser, InteractionRecord, TokenStat, SystemConfig
|
||||
from app.core.logger import logger
|
||||
|
||||
|
||||
class StatsService:
|
||||
|
||||
async def get_dashboard(self, db: AsyncSession) -> dict:
|
||||
"""获取控制台数据"""
|
||||
today = date.today()
|
||||
now = datetime.now()
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
# 用户统计
|
||||
user_stats = await self._get_user_stats(db)
|
||||
|
||||
# 今日互动统计
|
||||
today_stats = await self._get_today_stats(db, today)
|
||||
|
||||
# 本月互动统计
|
||||
monthly_stats = await self._get_monthly_stats(db, month_start, today)
|
||||
|
||||
# Token统计
|
||||
token_stats = await self._get_token_stats(db, today)
|
||||
|
||||
# 系统状态
|
||||
system_status = await self._get_system_status(db, now)
|
||||
|
||||
# 在线用户数
|
||||
online_count_result = await db.execute(
|
||||
select(func.count()).where(VirtualUser.status == 2)
|
||||
)
|
||||
online_count = online_count_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"user_stats": user_stats,
|
||||
"today_interactions": today_stats,
|
||||
"monthly_stats": monthly_stats,
|
||||
"token_stats": token_stats,
|
||||
"system_status": system_status,
|
||||
"online_users": online_count,
|
||||
}
|
||||
|
||||
async def _get_user_stats(self, db: AsyncSession) -> dict:
|
||||
total = await db.execute(select(func.count()).select_from(VirtualUser))
|
||||
normal = await db.execute(select(func.count()).where(VirtualUser.is_enabled == 1))
|
||||
banned = await db.execute(select(func.count()).where(VirtualUser.status == 4))
|
||||
abnormal = await db.execute(select(func.count()).where(VirtualUser.status == 3))
|
||||
return {
|
||||
"total": total.scalar() or 0,
|
||||
"normal": normal.scalar() or 0,
|
||||
"banned": banned.scalar() or 0,
|
||||
"abnormal": abnormal.scalar() or 0,
|
||||
}
|
||||
|
||||
async def _get_today_stats(self, db: AsyncSession, today: date) -> dict:
|
||||
result = await db.execute(
|
||||
select(
|
||||
InteractionRecord.interact_type,
|
||||
func.count().label("cnt"),
|
||||
).where(
|
||||
func.date(InteractionRecord.executed_at) == today,
|
||||
InteractionRecord.status == 1,
|
||||
).group_by(InteractionRecord.interact_type)
|
||||
)
|
||||
rows = result.all()
|
||||
stats = {"comment": 0, "reply": 0, "like": 0, "collect": 0, "forward": 0, "total": 0}
|
||||
for row in rows:
|
||||
if row.interact_type in stats:
|
||||
stats[row.interact_type] = row.cnt
|
||||
stats["total"] += row.cnt
|
||||
return stats
|
||||
|
||||
async def _get_monthly_stats(self, db: AsyncSession, month_start: date, today: date) -> dict:
|
||||
result = await db.execute(
|
||||
select(func.count()).where(
|
||||
InteractionRecord.executed_at >= month_start,
|
||||
InteractionRecord.status == 1,
|
||||
)
|
||||
)
|
||||
return {"total": result.scalar() or 0, "month_start": month_start.isoformat()}
|
||||
|
||||
async def _get_token_stats(self, db: AsyncSession, today: date) -> dict:
|
||||
# 今日
|
||||
today_stat = await db.execute(select(TokenStat).where(TokenStat.stat_date == today))
|
||||
today_row = today_stat.scalar_one_or_none()
|
||||
|
||||
# 每日限额
|
||||
limit_cfg = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.config_key == "daily_token_limit")
|
||||
)
|
||||
limit_row = limit_cfg.scalar_one_or_none()
|
||||
daily_limit = int(limit_row.config_value) if limit_row else 100000
|
||||
|
||||
today_used = today_row.total_tokens if today_row else 0
|
||||
return {
|
||||
"today_used": today_used,
|
||||
"daily_limit": daily_limit,
|
||||
"remaining": max(0, daily_limit - today_used),
|
||||
"today_calls": today_row.call_count if today_row else 0,
|
||||
}
|
||||
|
||||
async def _get_system_status(self, db: AsyncSession, now: datetime) -> dict:
|
||||
start_cfg = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.config_key == "system_start_time")
|
||||
)
|
||||
start_row = start_cfg.scalar_one_or_none()
|
||||
uptime = ""
|
||||
if start_row and start_row.config_value:
|
||||
try:
|
||||
start_time = datetime.fromisoformat(start_row.config_value)
|
||||
delta = now - start_time
|
||||
hours, rem = divmod(int(delta.total_seconds()), 3600)
|
||||
mins = rem // 60
|
||||
uptime = f"{hours}小时{mins}分钟"
|
||||
except Exception:
|
||||
uptime = "未知"
|
||||
|
||||
scheduler_cfg = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.config_key == "scheduler_enabled")
|
||||
)
|
||||
scheduler_row = scheduler_cfg.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"uptime": uptime,
|
||||
"scheduler_enabled": (scheduler_row.config_value == "true") if scheduler_row else True,
|
||||
"current_time": now.isoformat(),
|
||||
}
|
||||
|
||||
async def get_token_trend(self, db: AsyncSession, days: int = 30) -> list:
|
||||
"""Token消耗趋势(近N天)"""
|
||||
end_date = date.today()
|
||||
start_date = end_date - timedelta(days=days - 1)
|
||||
result = await db.execute(
|
||||
select(TokenStat).where(
|
||||
TokenStat.stat_date >= start_date,
|
||||
TokenStat.stat_date <= end_date,
|
||||
).order_by(TokenStat.stat_date)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
stat_map = {r.stat_date.isoformat(): r.total_tokens for r in rows}
|
||||
|
||||
trend = []
|
||||
for i in range(days):
|
||||
d = (start_date + timedelta(days=i)).isoformat()
|
||||
trend.append({"date": d, "tokens": stat_map.get(d, 0)})
|
||||
return trend
|
||||
|
||||
async def get_monthly_token_trend(self, db: AsyncSession) -> list:
|
||||
"""近12个月Token消耗"""
|
||||
today = date.today()
|
||||
months = []
|
||||
for i in range(11, -1, -1):
|
||||
if today.month - i <= 0:
|
||||
year = today.year - 1
|
||||
month = today.month - i + 12
|
||||
else:
|
||||
year = today.year
|
||||
month = today.month - i
|
||||
months.append((year, month))
|
||||
|
||||
trend = []
|
||||
for year, month in months:
|
||||
start = date(year, month, 1)
|
||||
if month == 12:
|
||||
end = date(year + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
end = date(year, month + 1, 1) - timedelta(days=1)
|
||||
result = await db.execute(
|
||||
select(func.sum(TokenStat.total_tokens)).where(
|
||||
TokenStat.stat_date >= start, TokenStat.stat_date <= end
|
||||
)
|
||||
)
|
||||
total = result.scalar() or 0
|
||||
trend.append({"month": f"{year}-{month:02d}", "tokens": total})
|
||||
return trend
|
||||
|
||||
async def get_interaction_records(
|
||||
self, db: AsyncSession,
|
||||
page: int = 1, page_size: int = 20,
|
||||
user_id: int = None, interact_type: str = None,
|
||||
status: int = None, start_date: str = None,
|
||||
end_date: str = None, keyword: str = None
|
||||
) -> dict:
|
||||
query = select(InteractionRecord)
|
||||
conditions = []
|
||||
if user_id:
|
||||
conditions.append(InteractionRecord.user_id == user_id)
|
||||
if interact_type:
|
||||
conditions.append(InteractionRecord.interact_type == interact_type)
|
||||
if status is not None:
|
||||
conditions.append(InteractionRecord.status == status)
|
||||
if start_date:
|
||||
conditions.append(InteractionRecord.executed_at >= start_date)
|
||||
if end_date:
|
||||
conditions.append(InteractionRecord.executed_at <= end_date + " 23:59:59")
|
||||
if keyword:
|
||||
from sqlalchemy import or_
|
||||
conditions.append(
|
||||
or_(InteractionRecord.article_title.like(f"%{keyword}%"),
|
||||
InteractionRecord.content.like(f"%{keyword}%"),
|
||||
InteractionRecord.user_nickname.like(f"%{keyword}%"))
|
||||
)
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(InteractionRecord.executed_at.desc()).offset(
|
||||
(page - 1) * page_size
|
||||
).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = result.scalars().all()
|
||||
|
||||
INTERACT_LABELS = {
|
||||
"comment": "评论", "reply": "回复", "like": "点赞",
|
||||
"collect": "收藏", "forward": "转发"
|
||||
}
|
||||
STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败"}
|
||||
|
||||
items = []
|
||||
for r in records:
|
||||
items.append({
|
||||
"id": r.id, "user_id": r.user_id,
|
||||
"user_nickname": r.user_nickname, "user_account": r.user_account,
|
||||
"article_id": r.article_id, "article_title": r.article_title,
|
||||
"interact_type": r.interact_type,
|
||||
"interact_type_label": INTERACT_LABELS.get(r.interact_type, r.interact_type),
|
||||
"content": r.content, "token_consumed": r.token_consumed,
|
||||
"status": r.status, "status_label": STATUS_LABELS.get(r.status, "未知"),
|
||||
"error_msg": r.error_msg, "retry_count": r.retry_count,
|
||||
"executed_at": _fmt_dt(r.executed_at),
|
||||
})
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": items}
|
||||
|
||||
|
||||
stats_service = StatsService()
|
||||
@@ -1,173 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
Executable
+358
@@ -0,0 +1,358 @@
|
||||
"""虚拟用户业务服务"""
|
||||
import io
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def _fmt_dt(dt):
|
||||
if dt is None: return None
|
||||
if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.isoformat()
|
||||
from typing import List, Optional, Tuple
|
||||
import pandas as pd
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, delete, func, and_, or_
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import VirtualUser, UserPersonality
|
||||
from app.schemas import UserCreateRequest, UserUpdateRequest
|
||||
from app.utils.crypto import encrypt, decrypt
|
||||
from app.services.ai_service import ai_service
|
||||
from app.core.logger import logger
|
||||
|
||||
STATUS_LABELS = {0: "未登录", 1: "登录中", 2: "已登录", 3: "登录失效", 4: "封禁"}
|
||||
ACTIVITY_LABELS = {0: "低", 1: "中", 2: "高"}
|
||||
ACTIVITY_COMMENT_LIMITS = {0: (3, 5), 1: (8, 15), 2: (20, 30)}
|
||||
|
||||
|
||||
class UserService:
|
||||
|
||||
async def get_users(
|
||||
self, db: AsyncSession,
|
||||
page: int = 1, page_size: int = 20,
|
||||
keyword: str = None, status: int = None,
|
||||
is_enabled: int = None
|
||||
) -> Tuple[int, List[dict]]:
|
||||
query = select(VirtualUser)
|
||||
conditions = []
|
||||
if keyword:
|
||||
conditions.append(
|
||||
or_(VirtualUser.nickname.like(f"%{keyword}%"),
|
||||
VirtualUser.account.like(f"%{keyword}%"))
|
||||
)
|
||||
if status is not None:
|
||||
conditions.append(VirtualUser.status == status)
|
||||
if is_enabled is not None:
|
||||
conditions.append(VirtualUser.is_enabled == is_enabled)
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(query.subquery())
|
||||
)
|
||||
total = count_result.scalar()
|
||||
|
||||
query = query.offset((page - 1) * page_size).limit(page_size).order_by(VirtualUser.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
|
||||
items = []
|
||||
for u in users:
|
||||
# 获取人格
|
||||
p_result = await db.execute(select(UserPersonality).where(UserPersonality.user_id == u.id))
|
||||
personality = p_result.scalar_one_or_none()
|
||||
items.append(self._format_user(u, personality))
|
||||
return total, items
|
||||
|
||||
async def create_user(self, db: AsyncSession, req: UserCreateRequest) -> dict:
|
||||
# 检查账号重复
|
||||
existing = await db.execute(select(VirtualUser).where(VirtualUser.account == req.account))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="账号已存在")
|
||||
# 昵称选填:为空则自动生成
|
||||
nickname = req.nickname or f"用户{req.account[-4:]}"
|
||||
# 检查昵称重复(自动生成的若冲突则加随机后缀)
|
||||
existing_nick = await db.execute(select(VirtualUser).where(VirtualUser.nickname == nickname))
|
||||
if existing_nick.scalar_one_or_none():
|
||||
import random, string
|
||||
nickname = nickname + "_" + "".join(random.choices(string.digits, k=4))
|
||||
|
||||
user = VirtualUser(
|
||||
nickname=nickname,
|
||||
account=req.account,
|
||||
password_enc=encrypt(req.password),
|
||||
avatar_url=req.avatar_url,
|
||||
activity_level=req.activity_level,
|
||||
daily_comment_limit=req.daily_comment_limit,
|
||||
daily_like_limit=req.daily_like_limit,
|
||||
remark=req.remark,
|
||||
status=0,
|
||||
is_enabled=1,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
# 自动生成AI人格
|
||||
try:
|
||||
await self._generate_personality(db, user)
|
||||
except Exception as e:
|
||||
logger.warning(f"人格生成失败,跳过: {e}")
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
p_result = await db.execute(select(UserPersonality).where(UserPersonality.user_id == user.id))
|
||||
personality = p_result.scalar_one_or_none()
|
||||
return self._format_user(user, personality)
|
||||
|
||||
async def update_user(self, db: AsyncSession, user_id: int, req: UserUpdateRequest) -> dict:
|
||||
user = await self._get_or_404(db, user_id)
|
||||
if req.nickname and req.nickname != user.nickname:
|
||||
existing = await db.execute(
|
||||
select(VirtualUser).where(VirtualUser.nickname == req.nickname, VirtualUser.id != user_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="昵称已被使用")
|
||||
user.nickname = req.nickname
|
||||
if req.password:
|
||||
user.password_enc = encrypt(req.password)
|
||||
if req.avatar_url is not None:
|
||||
user.avatar_url = req.avatar_url
|
||||
if req.activity_level is not None:
|
||||
user.activity_level = req.activity_level
|
||||
if req.daily_comment_limit is not None:
|
||||
user.daily_comment_limit = req.daily_comment_limit
|
||||
if req.daily_like_limit is not None:
|
||||
user.daily_like_limit = req.daily_like_limit
|
||||
if req.remark is not None:
|
||||
user.remark = req.remark
|
||||
if req.is_enabled is not None:
|
||||
user.is_enabled = req.is_enabled
|
||||
if req.is_enabled == 0:
|
||||
user.status = 0 # 禁用后重置状态
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
p_result = await db.execute(select(UserPersonality).where(UserPersonality.user_id == user.id))
|
||||
personality = p_result.scalar_one_or_none()
|
||||
return self._format_user(user, personality)
|
||||
|
||||
async def delete_user(self, db: AsyncSession, user_id: int):
|
||||
user = await self._get_or_404(db, user_id)
|
||||
await db.execute(delete(UserPersonality).where(UserPersonality.user_id == user_id))
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
async def batch_action(self, db: AsyncSession, user_ids: List[int], action: str):
|
||||
"""批量操作"""
|
||||
if action == "enable":
|
||||
await db.execute(update(VirtualUser).where(VirtualUser.id.in_(user_ids)).values(is_enabled=1))
|
||||
elif action == "disable":
|
||||
await db.execute(update(VirtualUser).where(VirtualUser.id.in_(user_ids)).values(is_enabled=0, status=0))
|
||||
elif action == "logout":
|
||||
await db.execute(update(VirtualUser).where(VirtualUser.id.in_(user_ids)).values(status=0, session_token=None))
|
||||
elif action == "delete":
|
||||
await db.execute(delete(UserPersonality).where(UserPersonality.user_id.in_(user_ids)))
|
||||
await db.execute(delete(VirtualUser).where(VirtualUser.id.in_(user_ids)))
|
||||
await db.commit()
|
||||
return {"affected": len(user_ids)}
|
||||
|
||||
async def generate_personality(self, db: AsyncSession, user_id: int) -> dict:
|
||||
"""为用户生成/重新生成AI人格"""
|
||||
user = await self._get_or_404(db, user_id)
|
||||
# 删除旧人格
|
||||
await db.execute(delete(UserPersonality).where(UserPersonality.user_id == user_id))
|
||||
personality = await self._generate_personality(db, user)
|
||||
await db.commit()
|
||||
return self._format_personality(personality)
|
||||
|
||||
async def update_personality(self, db: AsyncSession, user_id: int, req) -> dict:
|
||||
p_result = await db.execute(select(UserPersonality).where(UserPersonality.user_id == user_id))
|
||||
personality = p_result.scalar_one_or_none()
|
||||
if not personality:
|
||||
raise HTTPException(status_code=404, detail="人格不存在")
|
||||
for field, val in req.model_dump(exclude_none=True).items():
|
||||
setattr(personality, field, val)
|
||||
# 重新生成提示词
|
||||
personality.comment_style_prompt = self._build_style_prompt(personality)
|
||||
await db.commit()
|
||||
await db.refresh(personality)
|
||||
return self._format_personality(personality)
|
||||
|
||||
async def import_from_excel(self, db: AsyncSession, file_content: bytes) -> dict:
|
||||
"""Excel批量导入 - 每行独立事务,互不影响"""
|
||||
try:
|
||||
df = pd.read_excel(io.BytesIO(file_content), engine='openpyxl')
|
||||
except Exception:
|
||||
df = pd.read_excel(io.BytesIO(file_content))
|
||||
|
||||
df.columns = [str(c).strip() for c in df.columns]
|
||||
required_cols = {"新闻平台账号", "登录密码", "昵称"}
|
||||
if not required_cols.issubset(set(df.columns)):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"缺少必填列: {required_cols - set(df.columns)},当前列: {list(df.columns)}"
|
||||
)
|
||||
|
||||
success_count = 0
|
||||
error_list = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
row_num = idx + 2
|
||||
row_account = ""
|
||||
try:
|
||||
# 账号可能是数字类型(手机号),统一转为字符串
|
||||
account = str(row.get("新闻平台账号", "") or "").strip().split(".")[0] # 去掉 .0 后缀
|
||||
password = str(row.get("登录密码", "") or "").strip()
|
||||
nickname = str(row.get("昵称", "") or "").strip()
|
||||
row_account = account
|
||||
|
||||
if account.lower() in ("nan", "none", ""):
|
||||
error_list.append({"row": row_num, "error": "账号为空"}); continue
|
||||
if password.lower() in ("nan", "none", ""):
|
||||
error_list.append({"row": row_num, "account": account, "error": "密码为空"}); continue
|
||||
if len(password) < 6:
|
||||
error_list.append({"row": row_num, "account": account, "error": "密码不足6位"}); continue
|
||||
# 昵称选填:为空时自动用账号末4位生成
|
||||
if nickname.lower() in ("nan", "none", ""):
|
||||
nickname = f"用户{account[-4:]}"
|
||||
|
||||
existing = await db.execute(select(VirtualUser).where(VirtualUser.account == account))
|
||||
if existing.scalar_one_or_none():
|
||||
error_list.append({"row": row_num, "account": account, "error": "账号已存在"}); continue
|
||||
existing_nick = await db.execute(select(VirtualUser).where(VirtualUser.nickname == nickname))
|
||||
if existing_nick.scalar_one_or_none():
|
||||
error_list.append({"row": row_num, "account": account, "error": "昵称已被使用"}); continue
|
||||
|
||||
avatar = str(row.get("头像链接", "") or "").strip()
|
||||
remark = str(row.get("备注", "") or "").strip()
|
||||
|
||||
user = VirtualUser(
|
||||
nickname=nickname, account=account,
|
||||
password_enc=encrypt(password),
|
||||
avatar_url=avatar if avatar.lower() not in ("nan","none","") else None,
|
||||
remark=remark if remark.lower() not in ("nan","none","") else None,
|
||||
status=0, is_enabled=1, activity_level=1,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
await db.refresh(user)
|
||||
await self._generate_personality(db, user)
|
||||
await db.commit()
|
||||
except Exception as pe:
|
||||
logger.warning(f"第{row_num}行人格生成跳过: {pe}")
|
||||
await db.rollback()
|
||||
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
error_list.append({"row": row_num, "account": row_account, "error": str(e)})
|
||||
logger.warning(f"导入第{row_num}行失败: {e}")
|
||||
|
||||
return {"success": success_count, "failed": len(error_list), "errors": error_list}
|
||||
|
||||
async def export_to_excel(self, db: AsyncSession) -> bytes:
|
||||
"""导出全量用户数据(不含密码)"""
|
||||
result = await db.execute(select(VirtualUser).order_by(VirtualUser.created_at.desc()))
|
||||
users = result.scalars().all()
|
||||
rows = []
|
||||
for u in users:
|
||||
p_result = await db.execute(select(UserPersonality).where(UserPersonality.user_id == u.id))
|
||||
p = p_result.scalar_one_or_none()
|
||||
rows.append({
|
||||
"ID": u.id, "昵称": u.nickname, "账号": u.account,
|
||||
"状态": STATUS_LABELS.get(u.status, "未知"),
|
||||
"活跃度": ACTIVITY_LABELS.get(u.activity_level, "中"),
|
||||
"性格": p.character_type if p else "", "语言风格": p.language_style if p else "",
|
||||
"兴趣偏好": ",".join(p.interest_tags or []) if p else "",
|
||||
"互动倾向": p.interact_tendency if p else "",
|
||||
"累计互动": u.total_interactions, "今日评论": u.today_comment_count,
|
||||
"今日点赞": u.today_like_count, "最后登录": u.last_login_at,
|
||||
"最后互动": u.last_interact_at, "备注": u.remark,
|
||||
"是否启用": "是" if u.is_enabled else "否", "创建时间": u.created_at,
|
||||
})
|
||||
df = pd.DataFrame(rows)
|
||||
buf = io.BytesIO()
|
||||
df.to_excel(buf, index=False, sheet_name="虚拟用户")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
async def get_excel_template(self) -> bytes:
|
||||
"""获取导入模板(账号+密码必填,其他选填)"""
|
||||
df = pd.DataFrame(columns=["新闻平台账号", "登录密码", "昵称(选填)", "头像链接(选填)", "备注(选填)"])
|
||||
df.loc[0] = ["13800138000", "password123", "(留空自动生成)", "", ""]
|
||||
buf = io.BytesIO()
|
||||
df.to_excel(buf, index=False, sheet_name="导入模板")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
async def _generate_personality(self, db: AsyncSession, user: VirtualUser) -> UserPersonality:
|
||||
"""调用AI生成人格"""
|
||||
result = await ai_service.generate_personality(user.nickname, user.account)
|
||||
personality = UserPersonality(
|
||||
user_id=user.id,
|
||||
character_type=result.get("character_type", "温和"),
|
||||
language_style=result.get("language_style", "幽默"),
|
||||
interest_tags=result.get("interest_tags", ["科技"]),
|
||||
interact_tendency=result.get("interact_tendency", "爱评论"),
|
||||
word_count_min=result.get("word_count_min", 20),
|
||||
word_count_max=result.get("word_count_max", 80),
|
||||
personality_desc=result.get("personality_desc", ""),
|
||||
)
|
||||
personality.comment_style_prompt = self._build_style_prompt(personality)
|
||||
db.add(personality)
|
||||
await db.flush()
|
||||
return personality
|
||||
|
||||
def _build_style_prompt(self, p: UserPersonality) -> str:
|
||||
interests = "、".join(p.interest_tags or []) if p.interest_tags else "综合"
|
||||
return (
|
||||
f"你是一个{p.character_type}性格、{p.language_style}语言风格的新闻读者,"
|
||||
f"主要对{interests}类内容感兴趣,互动倾向是{p.interact_tendency}。"
|
||||
f"评论字数控制在{p.word_count_min}~{p.word_count_max}字。"
|
||||
f"个人简介:{p.personality_desc}"
|
||||
)
|
||||
|
||||
def _format_user(self, u: VirtualUser, p: Optional[UserPersonality]) -> dict:
|
||||
return {
|
||||
"id": u.id, "nickname": u.nickname, "account": u.account,
|
||||
"avatar_url": u.avatar_url,
|
||||
"real_name": getattr(u, "real_name", None),
|
||||
"sex": getattr(u, "sex", 0),
|
||||
"platform_uid": getattr(u, "platform_uid", None),
|
||||
"status": u.status,
|
||||
"status_label": STATUS_LABELS.get(u.status, "未知"),
|
||||
"activity_level": u.activity_level,
|
||||
"activity_label": ACTIVITY_LABELS.get(u.activity_level, "中"),
|
||||
"daily_comment_limit": u.daily_comment_limit,
|
||||
"daily_like_limit": u.daily_like_limit,
|
||||
"today_comment_count": u.today_comment_count,
|
||||
"today_like_count": u.today_like_count,
|
||||
"total_interactions": u.total_interactions,
|
||||
"last_login_at": _fmt_dt(u.last_login_at),
|
||||
"last_interact_at": _fmt_dt(u.last_interact_at),
|
||||
"remark": u.remark, "is_enabled": u.is_enabled,
|
||||
"created_at": _fmt_dt(u.created_at),
|
||||
"personality": self._format_personality(p) if p else None,
|
||||
}
|
||||
|
||||
def _format_personality(self, p: Optional[UserPersonality]) -> Optional[dict]:
|
||||
if not p:
|
||||
return None
|
||||
return {
|
||||
"id": p.id, "user_id": p.user_id,
|
||||
"character_type": p.character_type, "language_style": p.language_style,
|
||||
"interest_tags": p.interest_tags or [], "interact_tendency": p.interact_tendency,
|
||||
"word_count_min": p.word_count_min, "word_count_max": p.word_count_max,
|
||||
"personality_desc": p.personality_desc,
|
||||
"updated_at": _fmt_dt(p.updated_at),
|
||||
}
|
||||
|
||||
async def _get_or_404(self, db: AsyncSession, user_id: int) -> VirtualUser:
|
||||
result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
user_service = UserService()
|
||||
@@ -1,361 +0,0 @@
|
||||
"""
|
||||
虚拟用户管理服务
|
||||
"""
|
||||
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)
|
||||
Executable
Executable
+49
@@ -0,0 +1,49 @@
|
||||
"""AES加密工具 - 用于密码和API Key加密存储"""
|
||||
import base64
|
||||
import hashlib
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _get_key() -> bytes:
|
||||
"""获取32字节AES密钥"""
|
||||
key = settings.AES_KEY.encode("utf-8")
|
||||
return hashlib.sha256(key).digest()
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
"""AES-CBC加密"""
|
||||
if not plaintext:
|
||||
return ""
|
||||
key = _get_key()
|
||||
cipher = AES.new(key, AES.MODE_CBC)
|
||||
ct_bytes = cipher.encrypt(pad(plaintext.encode("utf-8"), AES.block_size))
|
||||
iv = base64.b64encode(cipher.iv).decode("utf-8")
|
||||
ct = base64.b64encode(ct_bytes).decode("utf-8")
|
||||
return f"{iv}:{ct}"
|
||||
|
||||
|
||||
def decrypt(ciphertext: str) -> str:
|
||||
"""AES-CBC解密"""
|
||||
if not ciphertext or ":" not in ciphertext:
|
||||
return ""
|
||||
try:
|
||||
iv_str, ct_str = ciphertext.split(":", 1)
|
||||
key = _get_key()
|
||||
iv = base64.b64decode(iv_str)
|
||||
ct = base64.b64decode(ct_str)
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
pt = unpad(cipher.decrypt(ct), AES.block_size)
|
||||
return pt.decode("utf-8")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def mask_password(password: str) -> str:
|
||||
"""密码脱敏显示"""
|
||||
if not password:
|
||||
return ""
|
||||
if len(password) <= 2:
|
||||
return "*" * len(password)
|
||||
return password[0] + "*" * (len(password) - 2) + password[-1]
|
||||
Regular → Executable
+22
-33
@@ -1,35 +1,24 @@
|
||||
# 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
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
pymysql==1.1.1
|
||||
cryptography==44.0.0
|
||||
redis==5.2.1
|
||||
apscheduler==3.10.4
|
||||
|
||||
# Excel Support
|
||||
openpyxl==3.1.2
|
||||
pandas==2.1.4
|
||||
|
||||
# Security
|
||||
python-jose[cryptography]==3.3.0
|
||||
pandas==2.2.3
|
||||
openpyxl==3.1.5
|
||||
passlib[bcrypt]==1.7.4
|
||||
|
||||
# Logging
|
||||
loguru==0.7.2
|
||||
|
||||
# Testing
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
pycryptodome==3.21.0
|
||||
httpx==0.28.1
|
||||
python-multipart==0.0.20
|
||||
python-jose[cryptography]==3.3.0
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.0
|
||||
openai==1.59.6
|
||||
langchain==0.3.13
|
||||
langchain-openai==0.3.0
|
||||
aiofiles==24.1.0
|
||||
loguru==0.7.3
|
||||
alembic==1.14.0
|
||||
aiomysql==0.2.0
|
||||
greenlet==3.1.1
|
||||
|
||||
+64
-61
@@ -1,80 +1,83 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# MySQL 数据库
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: huihui_mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root123456
|
||||
MYSQL_DATABASE: huihui_ai_bot
|
||||
MYSQL_USER: huihui_user
|
||||
MYSQL_PASSWORD: huihui_password
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- ./data/mysql:/var/lib/mysql
|
||||
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
- --default-time-zone=+00:00
|
||||
networks:
|
||||
- huihui_network
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# 后端应用服务
|
||||
backend:
|
||||
ai-virtual-backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: huihui_backend
|
||||
container_name: ai-virtual-backend
|
||||
restart: always
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
DATABASE_HOST: mysql
|
||||
DATABASE_PORT: 3306
|
||||
DATABASE_USER: huihui_user
|
||||
DATABASE_PASSWORD: huihui_password
|
||||
DATABASE_NAME: huihui_ai_bot
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DB_HOST=ai-virtual-mysql
|
||||
- DB_PORT=3306
|
||||
- DB_USER=aivirtual
|
||||
- DB_PASSWORD=AiVirtual2024
|
||||
- DB_NAME=ai_virtual_news
|
||||
- REDIS_HOST=ai-virtual-redis
|
||||
- REDIS_PORT=6379
|
||||
- SECRET_KEY=your-secret-key-change-in-production
|
||||
- AES_KEY=your-aes-key-32-chars-change-now!
|
||||
- TZ=Asia/Shanghai
|
||||
volumes:
|
||||
- ./data/uploads:/app/data/uploads
|
||||
- ./data/logs:/app/data/logs
|
||||
- ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建
|
||||
- ./backend/logs:/app/logs
|
||||
- ./backend/config:/app/config
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
- ai-virtual-mysql
|
||||
- ai-virtual-redis
|
||||
networks:
|
||||
- huihui_network
|
||||
command: >
|
||||
sh -c "sleep 10 && uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
- ai-virtual-net
|
||||
|
||||
# 前端服务(可选,如果需要独立部署前端)
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
container_name: huihui_frontend
|
||||
ai-virtual-frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: ai-virtual-frontend
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./frontend/dist:/usr/share/nginx/html
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
- "9000:80"
|
||||
depends_on:
|
||||
- backend
|
||||
- ai-virtual-backend
|
||||
networks:
|
||||
- huihui_network
|
||||
- ai-virtual-net
|
||||
|
||||
networks:
|
||||
huihui_network:
|
||||
driver: bridge
|
||||
ai-virtual-mysql:
|
||||
image: mysql:8.0
|
||||
container_name: ai-virtual-mysql
|
||||
restart: always
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=Root2024
|
||||
- MYSQL_DATABASE=ai_virtual_news
|
||||
- MYSQL_USER=aivirtual
|
||||
- MYSQL_PASSWORD=AiVirtual2024
|
||||
- TZ=Asia/Shanghai
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
networks:
|
||||
- ai-virtual-net
|
||||
|
||||
ai-virtual-redis:
|
||||
image: redis:6.0-alpine
|
||||
container_name: ai-virtual-redis
|
||||
restart: always
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- ai-virtual-net
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
ai-virtual-net:
|
||||
driver: bridge
|
||||
|
||||
+163
-11
@@ -1,15 +1,167 @@
|
||||
-- 初始化数据库表结构(如果使用 SQLAlchemy 自动创建,此文件可选)
|
||||
-- AI虚拟用户新闻互动系统 数据库初始化脚本
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- 创建扩展
|
||||
CREATE DATABASE IF NOT EXISTS huihui_ai_bot
|
||||
DEFAULT CHARACTER SET utf8mb4
|
||||
DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
-- 虚拟用户表
|
||||
CREATE TABLE IF NOT EXISTS `virtual_users` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`nickname` varchar(64) NOT NULL COMMENT '昵称',
|
||||
`account` varchar(128) NOT NULL COMMENT '新闻平台账号',
|
||||
`password_enc` varchar(512) NOT NULL COMMENT 'AES加密密码',
|
||||
`avatar_url` varchar(512) DEFAULT NULL COMMENT '头像URL',
|
||||
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0未登录 1登录中 2已登录 3登录失效 4封禁',
|
||||
`activity_level` tinyint NOT NULL DEFAULT 1 COMMENT '0低 1中 2高',
|
||||
`daily_comment_limit` int NOT NULL DEFAULT 10 COMMENT '每日最大评论次数',
|
||||
`daily_like_limit` int NOT NULL DEFAULT 30 COMMENT '每日最大点赞次数',
|
||||
`today_comment_count` int NOT NULL DEFAULT 0 COMMENT '今日已评论次数',
|
||||
`today_like_count` int NOT NULL DEFAULT 0 COMMENT '今日已点赞次数',
|
||||
`total_interactions` int NOT NULL DEFAULT 0 COMMENT '累计互动次数',
|
||||
`session_token` text DEFAULT NULL COMMENT '当前会话Token',
|
||||
`session_expires_at` datetime DEFAULT NULL COMMENT '会话过期时间',
|
||||
`last_login_at` datetime DEFAULT NULL COMMENT '最后登录时间',
|
||||
`last_interact_at` datetime DEFAULT NULL COMMENT '最后互动时间',
|
||||
`real_name` varchar(64) DEFAULT NULL COMMENT '真实姓名(平台同步)',
|
||||
`sex` tinyint(1) NOT NULL DEFAULT 0 COMMENT '性别 0未知 1男 2女',
|
||||
`platform_uid` varchar(64) DEFAULT NULL COMMENT '平台用户ID',
|
||||
`remark` varchar(256) DEFAULT NULL COMMENT '备注',
|
||||
`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0禁用',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_account` (`account`),
|
||||
UNIQUE KEY `uk_nickname` (`nickname`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='虚拟用户表';
|
||||
|
||||
USE huihui_ai_bot;
|
||||
-- 用户人格表
|
||||
CREATE TABLE IF NOT EXISTS `user_personalities` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`user_id` bigint NOT NULL COMMENT '用户ID',
|
||||
`character_type` varchar(32) DEFAULT NULL COMMENT '性格类型:开朗/内敛/毒舌/温和',
|
||||
`language_style` varchar(32) DEFAULT NULL COMMENT '语言风格:严肃/幽默/文艺/吐槽',
|
||||
`interest_tags` json DEFAULT NULL COMMENT '兴趣偏好JSON数组',
|
||||
`interact_tendency` varchar(32) DEFAULT NULL COMMENT '互动倾向:爱评论/爱点赞/潜水',
|
||||
`word_count_min` int DEFAULT 20 COMMENT '最少字数',
|
||||
`word_count_max` int DEFAULT 100 COMMENT '最多字数',
|
||||
`personality_desc` text DEFAULT NULL COMMENT 'AI生成的人格描述',
|
||||
`comment_style_prompt` text DEFAULT NULL COMMENT '评论风格提示词',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户人格表';
|
||||
|
||||
-- 插入初始系统配置数据
|
||||
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)
|
||||
-- 互动记录表
|
||||
CREATE TABLE IF NOT EXISTS `interaction_records` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`user_id` bigint NOT NULL COMMENT '虚拟用户ID',
|
||||
`user_nickname` varchar(64) DEFAULT NULL COMMENT '用户昵称',
|
||||
`user_account` varchar(128) DEFAULT NULL COMMENT '用户账号',
|
||||
`article_id` varchar(64) DEFAULT NULL COMMENT '文章ID',
|
||||
`article_title` varchar(256) DEFAULT NULL COMMENT '文章标题',
|
||||
`interact_type` varchar(16) NOT NULL COMMENT 'comment/reply/like/collect/forward',
|
||||
`content` text DEFAULT NULL COMMENT '评论/回复内容',
|
||||
`parent_comment_id` varchar(64) DEFAULT NULL COMMENT '父评论ID(回复时用)',
|
||||
`session_id` varchar(128) DEFAULT NULL COMMENT '登录会话ID',
|
||||
`token_consumed` int DEFAULT 0 COMMENT '消耗Token数',
|
||||
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0执行中 1成功 2失败',
|
||||
`error_msg` varchar(512) DEFAULT NULL COMMENT '失败原因',
|
||||
`retry_count` tinyint DEFAULT 0 COMMENT '重试次数',
|
||||
`executed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_executed_at` (`executed_at`),
|
||||
KEY `idx_interact_type` (`interact_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='互动记录表';
|
||||
|
||||
-- Token消耗统计表
|
||||
CREATE TABLE IF NOT EXISTS `token_stats` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`stat_date` date NOT NULL COMMENT '统计日期',
|
||||
`model_name` varchar(64) DEFAULT NULL COMMENT '模型名称',
|
||||
`total_tokens` int NOT NULL DEFAULT 0 COMMENT '当日消耗总Token',
|
||||
`prompt_tokens` int NOT NULL DEFAULT 0 COMMENT 'Prompt Token',
|
||||
`completion_tokens` int NOT NULL DEFAULT 0 COMMENT 'Completion Token',
|
||||
`call_count` int NOT NULL DEFAULT 0 COMMENT '调用次数',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_stat_date` (`stat_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Token消耗统计表';
|
||||
|
||||
-- AI模型配置表
|
||||
CREATE TABLE IF NOT EXISTS `ai_model_configs` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`model_name` varchar(64) NOT NULL COMMENT '模型名称',
|
||||
`provider` varchar(32) NOT NULL COMMENT 'openai/zhipu/wenxin/qianwen/local',
|
||||
`api_base_url` varchar(256) DEFAULT NULL COMMENT 'API地址',
|
||||
`api_key_enc` varchar(512) DEFAULT NULL COMMENT '加密API Key',
|
||||
`model_version` varchar(64) DEFAULT NULL COMMENT '模型版本',
|
||||
`temperature` float DEFAULT 0.7 COMMENT '温度',
|
||||
`max_tokens` int DEFAULT 1000 COMMENT '最大Token',
|
||||
`timeout_seconds` int DEFAULT 30 COMMENT '超时秒数',
|
||||
`is_default` tinyint NOT NULL DEFAULT 0 COMMENT '是否默认模型',
|
||||
`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI模型配置表';
|
||||
|
||||
-- 系统配置表
|
||||
CREATE TABLE IF NOT EXISTS `system_configs` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`config_key` varchar(64) NOT NULL COMMENT '配置键',
|
||||
`config_value` text DEFAULT NULL COMMENT '配置值',
|
||||
`config_type` varchar(16) DEFAULT 'string' COMMENT '类型:string/int/json/bool',
|
||||
`description` varchar(256) DEFAULT NULL COMMENT '说明',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_config_key` (`config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表';
|
||||
|
||||
-- 登录日志表
|
||||
CREATE TABLE IF NOT EXISTS `login_logs` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`user_id` bigint NOT NULL,
|
||||
`user_account` varchar(128) DEFAULT NULL,
|
||||
`action` varchar(16) NOT NULL COMMENT 'login/logout/refresh/fail',
|
||||
`session_id` varchar(128) DEFAULT NULL,
|
||||
`ip_address` varchar(64) DEFAULT NULL,
|
||||
`error_msg` varchar(512) DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录日志表';
|
||||
|
||||
-- 初始系统配置
|
||||
INSERT INTO `system_configs` (`config_key`, `config_value`, `config_type`, `description`) VALUES
|
||||
('interact_time_start', '08:00', 'string', '互动开始时间'),
|
||||
('interact_time_end', '22:00', 'string', '互动结束时间'),
|
||||
('interact_interval_min', '300', 'int', '互动最小间隔(秒)'),
|
||||
('interact_interval_max', '1800', 'int', '互动最大间隔(秒)'),
|
||||
('max_concurrent_users', '5', 'int', '并发登录用户数上限'),
|
||||
('daily_token_limit', '100000', 'int', '每日全局Token上限'),
|
||||
('today_token_used', '0', 'int', '今日已使用Token'),
|
||||
('token_limit_reached', 'false', 'bool', 'Token限额是否已达上限'),
|
||||
-- 目标平台接口配置
|
||||
('news_platform_base_url', 'http://192.168.1.200:63120', 'string', '新闻业务平台接口地址'),
|
||||
('auth_base_url', 'http://192.168.1.200:60040', 'string', '认证服务接口地址'),
|
||||
-- 目标平台客户端参数(需根据实际情况配置)
|
||||
('platform_app_id', '', 'string', '平台appId(客户端标识)'),
|
||||
('platform_access_id', '', 'string', '平台accessId(客户端accessId)'),
|
||||
('platform_access_secret', '', 'string', '平台accessSecret(签名密钥,可为空)'),
|
||||
('platform_client_code', '', 'string', '平台clientCode(登录用)'),
|
||||
('platform_org_id', '', 'string', '平台组织Id(orgId,新闻列表必填)'),
|
||||
-- 互动概率
|
||||
('comment_probability', '0.4', 'string', '评论触发概率'),
|
||||
('reply_probability', '0.2', 'string', '回复触发概率'),
|
||||
('like_probability', '0.6', 'string', '点赞触发概率'),
|
||||
('collect_probability', '0.3', 'string', '收藏触发概率'),
|
||||
('forward_probability', '0.15', 'string', '转发触发概率'),
|
||||
('system_start_time', NULL, 'string', '系统启动时间'),
|
||||
('scheduler_enabled', 'true', 'bool', '调度器是否启用')
|
||||
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value);
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
<!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>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI虚拟用户新闻互动系统</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Vue Router history mode
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://ai-virtual-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_read_timeout 120s;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript;
|
||||
}
|
||||
Generated
+1777
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,24 @@
|
||||
{
|
||||
"name": "huihui-ai-bot-frontend",
|
||||
"name": "ai-virtual-news-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue": "^3.3.8",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"element-plus": "^2.5.0",
|
||||
"axios": "^1.6.5",
|
||||
"echarts": "^5.4.3",
|
||||
"element-plus": "^2.4.3",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"echarts": "^5.4.3",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.10",
|
||||
"sass": "^1.69.5"
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-20
@@ -1,22 +1,3 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
<router-view />
|
||||
</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>
|
||||
|
||||
+83
-49
@@ -1,56 +1,90 @@
|
||||
import request from './request'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 仪表盘 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}`)
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 递归把响应里所有 +00:00 时区标识替换为 +08:00(数据库存北京时间,后端误标为 UTC)
|
||||
function fixDatetimeTZ(obj) {
|
||||
if (typeof obj === 'string') {
|
||||
return obj.replace(/T(\d{2}:\d{2}:\d{2})\+00:00/g, 'T$1+08:00')
|
||||
}
|
||||
if (Array.isArray(obj)) return obj.map(fixDatetimeTZ)
|
||||
if (obj && typeof obj === 'object') {
|
||||
const result = {}
|
||||
for (const k in obj) result[k] = fixDatetimeTZ(obj[k])
|
||||
return result
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// 虚拟用户 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 }
|
||||
})
|
||||
request.interceptors.response.use(
|
||||
res => {
|
||||
const data = fixDatetimeTZ(res.data)
|
||||
if (data.code && data.code !== 200) {
|
||||
ElMessage.error(data.message || '请求失败')
|
||||
return Promise.reject(new Error(data.message))
|
||||
}
|
||||
return data
|
||||
},
|
||||
getStats: (id) => request.get(`/virtual-users/${id}/stats`)
|
||||
}
|
||||
err => {
|
||||
const msg = err.response?.data?.detail || err.response?.data?.message || err.message || '网络错误'
|
||||
ElMessage.error(msg)
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
// 互动记录 API
|
||||
export const interactionApi = {
|
||||
getList: (params) => request.get('/interactions', { params }),
|
||||
execute: (data) => request.post('/interactions/execute', data),
|
||||
retry: (id) => request.post(`/interactions/retry/${id}`)
|
||||
}
|
||||
// Dashboard
|
||||
export const getDashboard = () => request.get('/dashboard')
|
||||
export const getTokenTrend = (days = 30) => request.get('/dashboard/token-trend', { params: { days } })
|
||||
export const getMonthlyTokenTrend = () => request.get('/dashboard/monthly-token-trend')
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Users
|
||||
export const getUsers = (params) => request.get('/users', { params })
|
||||
export const createUser = (data) => request.post('/users', data)
|
||||
export const updateUser = (id, data) => request.put(`/users/${id}`, data)
|
||||
export const deleteUser = (id) => request.delete(`/users/${id}`)
|
||||
export const batchUserAction = (data) => request.post('/users/batch/action', data)
|
||||
export const loginUser = (id) => request.post(`/users/${id}/login`)
|
||||
export const logoutUser = (id) => request.post(`/users/${id}/logout`)
|
||||
export const generatePersonality = (id) => request.post(`/users/${id}/personality/generate`)
|
||||
export const updatePersonality = (id, data) => request.put(`/users/${id}/personality`, data)
|
||||
export const importUsers = (formData) => request.post('/users/excel/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
export const downloadTemplate = () => request.get('/users/excel/template', { responseType: 'blob' })
|
||||
export const exportUsers = () => request.get('/users/excel/export', { responseType: 'blob' })
|
||||
export const deduplicateUsers = () => request.post('/users/deduplicate')
|
||||
export const clearAllUsers = () => request.post('/users/clear-all')
|
||||
export const loginAllUsers = () => request.post('/users/login-all')
|
||||
export const syncAllProfiles = () => request.post('/users/sync-all-profiles')
|
||||
export const cancelInteraction = (id) => request.post(`/interactions/${id}/cancel`)
|
||||
export const runInteractionNow = () => request.post('/system/interaction/run-now')
|
||||
|
||||
// 系统配置 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')
|
||||
}
|
||||
// Interactions
|
||||
export const getInteractions = (params) => request.get('/interactions', { params })
|
||||
export const retryInteraction = (id) => request.post(`/interactions/${id}/retry`)
|
||||
export const exportInteractions = (params) => request.get('/interactions/export', { params, responseType: 'blob' })
|
||||
|
||||
// AI Models
|
||||
export const getAIModels = () => request.get('/ai-models')
|
||||
export const createAIModel = (data) => request.post('/ai-models', data)
|
||||
export const updateAIModel = (id, data) => request.put(`/ai-models/${id}`, data)
|
||||
export const deleteAIModel = (id) => request.delete(`/ai-models/${id}`)
|
||||
export const testAIModel = (data) => request.post('/ai-models/test', data)
|
||||
|
||||
// System
|
||||
export const getSystemConfigs = () => request.get('/system/configs')
|
||||
export const updateSystemConfigs = (data) => request.put('/system/configs', data)
|
||||
export const toggleScheduler = (enabled) => request.post('/system/scheduler/toggle', { enabled })
|
||||
export const resetAllSessions = () => request.post('/system/sessions/reset-all')
|
||||
|
||||
// Logs
|
||||
export const getLoginLogs = (params) => request.get('/logs/login', { params })
|
||||
export const getLogFiles = () => request.get('/logs/files')
|
||||
export const tailLogFile = (filename, lines = 100) => request.get(`/logs/files/${filename}/tail`, { params: { lines } })
|
||||
|
||||
export default request
|
||||
export const uploadAvatar = (userId, formData) => request.post(`/users/${userId}/upload-avatar`, formData, { headers: { "Content-Type": "multipart/form-data" } })
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<span class="logo-icon">🤖</span>
|
||||
<span class="logo-text">AI互动系统</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" router class="sidebar-menu">
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>数据看板</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>虚拟用户</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/interactions">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>互动记录</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/ai-models">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<span>AI模型配置</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/scheduler">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>调度设置</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/logs">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>日志管理</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="sidebar-footer">
|
||||
<div class="system-status">
|
||||
<span class="status-dot" :class="schedulerEnabled ? 'active' : 'paused'"></span>
|
||||
<span>{{ schedulerEnabled ? '调度运行中' : '调度已暂停' }}</span>
|
||||
</div>
|
||||
<div class="version">v1.0.0</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="main-wrapper">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="page-title-bar">{{ currentTitle }}</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="online-badge">
|
||||
<span class="pulse"></span>
|
||||
<span>{{ onlineUsers }} 在线</span>
|
||||
</div>
|
||||
<div class="token-badge">
|
||||
<el-icon><Coin /></el-icon>
|
||||
<span>今日剩余 {{ tokenRemaining.toLocaleString() }}</span>
|
||||
</div>
|
||||
<el-button size="small" circle @click="refreshAll">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getDashboard } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const activeMenu = computed(() => route.path)
|
||||
const currentTitle = computed(() => route.meta?.title || 'AI虚拟用户新闻互动系统')
|
||||
|
||||
const onlineUsers = ref(0)
|
||||
const tokenRemaining = ref(0)
|
||||
const schedulerEnabled = ref(true)
|
||||
let timer = null
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await getDashboard()
|
||||
onlineUsers.value = res.data?.online_users || 0
|
||||
tokenRemaining.value = res.data?.token_stats?.remaining || 0
|
||||
schedulerEnabled.value = res.data?.system_status?.scheduler_enabled ?? true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
fetchStatus()
|
||||
window.dispatchEvent(new CustomEvent('page-refresh'))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStatus()
|
||||
timer = setInterval(fetchStatus, 300000) // 5min
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.logo-icon { font-size: 24px; }
|
||||
.logo-text {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
:deep(.el-menu-item) {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.system-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot.active {
|
||||
background: var(--color-accent-green);
|
||||
box-shadow: 0 0 6px var(--color-accent-green);
|
||||
animation: blink 2s infinite;
|
||||
}
|
||||
.status-dot.paused { background: var(--color-accent-orange); }
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||
.version { font-size: 11px; color: var(--color-text-muted); opacity: 0.5; }
|
||||
|
||||
.main-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 56px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.page-title-bar { font-size: 15px; font-weight: 600; color: var(--color-text); }
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.online-badge, .token-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.pulse {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-green);
|
||||
animation: blink 1.5s infinite;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
+6
-10
@@ -1,25 +1,21 @@
|
||||
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 zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/global.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册 Element Plus 图标
|
||||
// Register all Element Plus icons
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(pinia)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
app.use(ElementPlus, { locale: zhCn, size: 'default' })
|
||||
app.mount('#app')
|
||||
|
||||
@@ -3,46 +3,20 @@ 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: '系统设置' }
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '数据看板' } },
|
||||
{ path: 'users', component: () => import('@/views/Users.vue'), meta: { title: '虚拟用户管理' } },
|
||||
{ path: 'interactions', component: () => import('@/views/Interactions.vue'), meta: { title: '互动记录' } },
|
||||
{ path: 'ai-models', component: () => import('@/views/AIModels.vue'), meta: { title: 'AI模型配置' } },
|
||||
{ path: 'scheduler', component: () => import('@/views/Scheduler.vue'), meta: { title: '调度设置' } },
|
||||
{ path: 'logs', component: () => import('@/views/Logs.vue'), meta: { title: '日志管理' } },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - 会会虚拟用户 AI 互动系统`
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
:root {
|
||||
--color-bg: #f5f7fa;
|
||||
--color-bg-secondary: #ffffff;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-border: #e4e7ed;
|
||||
--color-border-light: #ebeef5;
|
||||
--color-text: #303133;
|
||||
--color-text-muted: #909399;
|
||||
--color-accent: #409eff;
|
||||
--color-accent-green: #67c23a;
|
||||
--color-accent-orange: #e6a23c;
|
||||
--color-accent-red: #f56c6c;
|
||||
--color-accent-purple: #9c7ff5;
|
||||
--color-accent-yellow: #e6a23c;
|
||||
--sidebar-width: 220px;
|
||||
--shadow-sm: 0 1px 4px rgba(0,0,0,0.06);
|
||||
--shadow-md: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-bg-color: #ffffff !important;
|
||||
--el-table-tr-bg-color: #ffffff !important;
|
||||
--el-table-header-bg-color: #f5f7fa !important;
|
||||
--el-table-border-color: var(--color-border) !important;
|
||||
--el-table-text-color: var(--color-text) !important;
|
||||
--el-table-header-text-color: #606266 !important;
|
||||
--el-table-row-hover-bg-color: #f0f7ff !important;
|
||||
--el-table-current-row-bg-color: #ecf5ff !important;
|
||||
--el-fill-color-lighter: #f5f7fa !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden !important;
|
||||
box-shadow: var(--shadow-sm) !important;
|
||||
}
|
||||
.el-table__body tr.el-table__row { background: #ffffff !important; }
|
||||
.el-table__body tr.el-table__row--striped td { background: #fafafa !important; }
|
||||
.el-table__header-wrapper { background: #f5f7fa !important; }
|
||||
|
||||
.el-card {
|
||||
--el-card-bg-color: #ffffff !important;
|
||||
--el-card-border-color: var(--color-border) !important;
|
||||
color: var(--color-text) !important;
|
||||
box-shadow: var(--shadow-sm) !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
--el-dialog-bg-color: #ffffff !important;
|
||||
--el-dialog-border-radius: 12px !important;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12) !important;
|
||||
}
|
||||
.el-dialog__header { border-bottom: 1px solid var(--color-border) !important; padding: 16px 20px !important; }
|
||||
.el-dialog__footer { border-top: 1px solid var(--color-border) !important; padding: 12px 20px !important; }
|
||||
|
||||
.el-input__wrapper, .el-select__wrapper, .el-textarea__inner {
|
||||
background-color: #ffffff !important;
|
||||
box-shadow: 0 0 0 1px var(--color-border) inset !important;
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
.el-input__wrapper:hover, .el-select__wrapper:hover { box-shadow: 0 0 0 1px #c0c4cc inset !important; }
|
||||
.el-input__wrapper.is-focus, .el-select__wrapper.is-focused { box-shadow: 0 0 0 1px var(--color-accent) inset !important; }
|
||||
.el-input__inner, .el-textarea__inner { color: var(--color-text) !important; background: transparent !important; }
|
||||
|
||||
.el-button--primary { background: var(--color-accent) !important; border-color: var(--color-accent) !important; color: #ffffff !important; }
|
||||
.el-button--primary:hover { background: #66b1ff !important; border-color: #66b1ff !important; }
|
||||
.el-button--success { background: var(--color-accent-green) !important; border-color: var(--color-accent-green) !important; color: #fff !important; }
|
||||
.el-button--danger { background: var(--color-accent-red) !important; border-color: var(--color-accent-red) !important; color: #fff !important; }
|
||||
.el-button--default { background: #ffffff !important; border-color: var(--color-border) !important; color: var(--color-text) !important; }
|
||||
.el-button--default:hover { border-color: var(--color-accent) !important; color: var(--color-accent) !important; }
|
||||
.el-button--warning { background: var(--color-accent-orange) !important; border-color: var(--color-accent-orange) !important; color: #fff !important; }
|
||||
|
||||
.el-tag { border-radius: 6px !important; font-size: 12px !important; }
|
||||
.el-tag--success { background: #f0f9eb !important; border-color: #b3e19d !important; color: #67c23a !important; }
|
||||
.el-tag--danger { background: #fef0f0 !important; border-color: #fbc4c4 !important; color: #f56c6c !important; }
|
||||
.el-tag--warning { background: #fdf6ec !important; border-color: #f5dab1 !important; color: #e6a23c !important; }
|
||||
.el-tag--info { background: #f4f4f5 !important; border-color: #d3d4d6 !important; color: #909399 !important; }
|
||||
|
||||
.el-pagination {
|
||||
--el-pagination-bg-color: transparent !important;
|
||||
--el-pagination-text-color: var(--color-text) !important;
|
||||
--el-pagination-button-color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
.el-form-item__label { color: #606266 !important; font-weight: 500 !important; }
|
||||
.el-radio__label { color: var(--color-text) !important; }
|
||||
.el-checkbox__label { color: var(--color-text) !important; }
|
||||
|
||||
.el-menu { background: transparent !important; border: none !important; }
|
||||
.el-menu-item, .el-sub-menu__title {
|
||||
color: #606266 !important;
|
||||
border-radius: 8px !important;
|
||||
margin: 2px 8px !important;
|
||||
transition: all 0.2s !important;
|
||||
}
|
||||
.el-menu-item.is-active { background: #ecf5ff !important; color: var(--color-accent) !important; font-weight: 600 !important; }
|
||||
.el-menu-item:hover { background: #f0f7ff !important; color: var(--color-accent) !important; }
|
||||
|
||||
.el-dropdown-menu { background: #ffffff !important; border: 1px solid var(--color-border) !important; box-shadow: var(--shadow-md) !important; }
|
||||
.el-dropdown-menu__item { color: var(--color-text) !important; }
|
||||
.el-dropdown-menu__item:hover { background: #f0f7ff !important; color: var(--color-accent) !important; }
|
||||
|
||||
.el-select-dropdown { background: #ffffff !important; border: 1px solid var(--color-border) !important; box-shadow: var(--shadow-md) !important; }
|
||||
.el-select-dropdown__item { color: var(--color-text) !important; }
|
||||
.el-select-dropdown__item.is-hovering { background: #f0f7ff !important; }
|
||||
.el-select-dropdown__item.is-selected { color: var(--color-accent) !important; font-weight: 600 !important; }
|
||||
|
||||
.el-popover { background: #ffffff !important; border: 1px solid var(--color-border) !important; color: var(--color-text) !important; }
|
||||
.el-alert { border-radius: 8px !important; }
|
||||
.el-switch__core { border-color: #dcdfe6 !important; }
|
||||
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #c0c4cc; }
|
||||
|
||||
.page-container { padding: 24px; height: 100%; overflow-y: auto; background: var(--color-bg); }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; color: var(--color-text); }
|
||||
.stat-card { background: #ffffff; border: 1px solid var(--color-border); border-radius: 12px; padding: 20px; box-shadow: var(--shadow-sm); transition: box-shadow 0.2s; }
|
||||
.stat-card:hover { box-shadow: var(--shadow-md); }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: var(--color-accent); }
|
||||
.stat-label { font-size: 13px; color: var(--color-text-muted); margin-top: 4px; }
|
||||
.sidebar { background: #ffffff !important; border-right: 1px solid var(--color-border) !important; box-shadow: 1px 0 8px rgba(0,0,0,0.04) !important; }
|
||||
+190
-226
@@ -1,275 +1,239 @@
|
||||
<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 class="page-container">
|
||||
<div class="page-header">
|
||||
<span class="page-title">AI模型配置</span>
|
||||
<el-button type="primary" size="small" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon> 添加模型
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="models-grid">
|
||||
<div v-for="m in models" :key="m.id" class="model-card" :class="{ 'is-default': m.is_default }">
|
||||
<div class="model-head">
|
||||
<div class="model-name">
|
||||
<span class="provider-badge" :class="m.provider">{{ providerLabels[m.provider] || m.provider }}</span>
|
||||
<span class="model-title">{{ m.model_name }}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<el-tag v-if="m.is_default" type="success" size="small">默认</el-tag>
|
||||
<el-tag v-if="!m.is_enabled" type="danger" size="small">禁用</el-tag>
|
||||
</div>
|
||||
</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" />
|
||||
<div class="model-meta">
|
||||
<span>版本: {{ m.model_version || '--' }}</span>
|
||||
<span>温度: {{ m.temperature }}</span>
|
||||
<span>Max Tokens: {{ m.max_tokens }}</span>
|
||||
<span>超时: {{ m.timeout_seconds }}s</span>
|
||||
<span>API Key: {{ m.has_api_key ? '✓ 已配置' : '✗ 未配置' }}</span>
|
||||
</div>
|
||||
<div class="model-actions">
|
||||
<el-button size="small" @click="openTest(m)">测试</el-button>
|
||||
<el-button size="small" @click="openEdit(m)">编辑</el-button>
|
||||
<el-button v-if="!m.is_default" size="small" type="primary" @click="setDefault(m)">设为默认</el-button>
|
||||
<el-button size="small" type="danger" @click="doDelete(m)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!models.length" class="empty-state">
|
||||
<el-empty description="暂无模型配置,请添加AI模型" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editModel ? '编辑模型' : '添加AI模型'" width="540px">
|
||||
<el-form :model="form" label-width="110px" :rules="rules" ref="formRef">
|
||||
<el-form-item label="模型名称" prop="model_name">
|
||||
<el-input v-model="form.model_name" placeholder="如: GPT-4 生产环境" />
|
||||
</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-form-item label="提供商" prop="provider">
|
||||
<el-select v-model="form.provider" style="width:100%" @change="onProviderChange">
|
||||
<el-option v-for="(l,v) in providerLabels" :key="v" :label="l" :value="v" />
|
||||
</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 label="API地址">
|
||||
<el-input v-model="form.api_base_url" placeholder="留空使用默认地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="modelForm.api_key" type="password" show-password placeholder="sk-..." />
|
||||
<el-input v-model="form.api_key" type="password" show-password :placeholder="editModel ? '不修改请留空' : '输入API Key'" />
|
||||
</el-form-item>
|
||||
<el-form-item label="温度">
|
||||
<el-slider v-model="modelForm.temperature" :min="0" :max="1" :step="0.1" />
|
||||
<el-form-item label="模型版本">
|
||||
<el-input v-model="form.model_version" placeholder="如: gpt-4-turbo, glm-4" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大 Token 数">
|
||||
<el-input-number v-model="modelForm.max_tokens" :min="1" :max="4096" />
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="温度">
|
||||
<el-input-number v-model="form.temperature" :min="0" :max="2" :step="0.1" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Max Tokens">
|
||||
<el-input-number v-model="form.max_tokens" :min="100" :max="32000" :step="100" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="超时(秒)">
|
||||
<el-input-number v-model="form.timeout_seconds" :min="5" :max="300" style="width:160px" />
|
||||
</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-switch v-model="form.is_default" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showModelDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="saving">保存</el-button>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submit" :loading="submitting">保存</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="请输入测试内容,如:请写一条关于春天的评论"
|
||||
/>
|
||||
|
||||
<!-- Test Dialog -->
|
||||
<el-dialog v-model="testVisible" title="模型测试" width="560px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="测试指令">
|
||||
<el-input v-model="testPrompt" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-if="testResult" class="test-result">
|
||||
<div class="result-meta">
|
||||
<el-tag :type="testResult.success ? 'success' : 'danger'">
|
||||
{{ testResult.success ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
<span v-if="testResult.success" style="font-size:12px;color:var(--color-text-muted)">
|
||||
耗时 {{ testResult.elapsed_seconds }}s · 消耗 {{ testResult.tokens }} tokens
|
||||
</span>
|
||||
</div>
|
||||
<div class="result-content" style="white-space:pre-wrap;word-break:break-all;">
|
||||
{{ testResult.success ? (testResult.content || '(响应为空)') : testResult.error }}
|
||||
</div>
|
||||
<div v-if="testResult.success" style="margin-top:8px;font-size:12px;color:var(--color-text-muted)">
|
||||
耗时 {{ testResult.elapsed_seconds }}s · Token {{ testResult.tokens }}
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showTestDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleRunTest" :loading="testing">测试</el-button>
|
||||
<el-button @click="testVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="runTest" :loading="testing">发送测试</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { aiModelApi } from '@/api'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getAIModels, createAIModel, updateAIModel, deleteAIModel, testAIModel } from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const models = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editModel = ref(null)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const testVisible = ref(false)
|
||||
const testingModel = ref(null)
|
||||
const testPrompt = ref('你好,请简单介绍一下你自己,并写一条关于"人工智能改变新闻业"的新闻评论。')
|
||||
const testResult = ref(null)
|
||||
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 providerLabels = { openai: 'OpenAI', zhipu: '智谱GLM', wenxin: '文心一言', qianwen: '通义千问', local: '本地模型' }
|
||||
const form = reactive({ model_name: '', provider: 'openai', api_base_url: '', api_key: '', model_version: '', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||
const rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }] }
|
||||
|
||||
const testData = ref({})
|
||||
async function load() {
|
||||
const res = await getAIModels()
|
||||
models.value = res.data || []
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
const PROVIDER_DEFAULTS = {
|
||||
openai: { api_base_url: 'https://api.openai.com/v1', model_version: 'gpt-4-turbo' },
|
||||
zhipu: { api_base_url: 'https://open.bigmodel.cn/api/paas/v4', model_version: 'glm-4' },
|
||||
wenxin: { api_base_url: 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat', model_version: 'ERNIE-Bot-4' },
|
||||
qianwen: { api_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model_version: 'qwen-turbo' },
|
||||
local: { api_base_url: 'http://127.0.0.1:11434/v1', model_version: 'qwen2' },
|
||||
}
|
||||
|
||||
function onProviderChange(provider) {
|
||||
const def = PROVIDER_DEFAULTS[provider] || {}
|
||||
form.api_base_url = def.api_base_url || ''
|
||||
form.model_version = def.model_version || ''
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editModel.value = null
|
||||
Object.assign(form, { model_name: '', provider: 'openai', api_base_url: PROVIDER_DEFAULTS.openai.api_base_url, api_key: '', model_version: PROVIDER_DEFAULTS.openai.model_version, temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(m) {
|
||||
editModel.value = m
|
||||
Object.assign(form, { model_name: m.model_name, provider: m.provider, api_base_url: m.api_base_url || '', api_key: '', model_version: m.model_version || '', temperature: m.temperature, max_tokens: m.max_tokens, timeout_seconds: m.timeout_seconds, is_default: m.is_default })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await formRef.value.validate()
|
||||
submitting.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('更新成功')
|
||||
const data = { ...form }
|
||||
if (editModel.value) {
|
||||
if (!data.api_key) delete data.api_key
|
||||
await updateAIModel(editModel.value.id, data)
|
||||
} else {
|
||||
await aiModelApi.create(modelForm.value)
|
||||
ElMessage.success('创建成功')
|
||||
await createAIModel(data)
|
||||
}
|
||||
showModelDialog.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('Save model error:', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} finally { submitting.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)
|
||||
}
|
||||
})
|
||||
async function doDelete(m) {
|
||||
await ElMessageBox.confirm(`确认删除模型 "${m.model_name}"?`, '确认', { type: 'warning' })
|
||||
await deleteAIModel(m.id)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
}
|
||||
|
||||
// 测试
|
||||
const handleTest = (row) => {
|
||||
currentModelId.value = row.id
|
||||
testPrompt.value = '请写一条简短的评论'
|
||||
showTestDialog.value = true
|
||||
async function setDefault(m) {
|
||||
await updateAIModel(m.id, { is_default: 1 })
|
||||
ElMessage.success(`"${m.model_name}" 已设为默认模型`)
|
||||
load()
|
||||
}
|
||||
|
||||
const handleRunTest = async () => {
|
||||
function openTest(m) {
|
||||
testingModel.value = m
|
||||
testResult.value = null
|
||||
testVisible.value = true
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
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 res = await testAIModel({ model_id: testingModel.value.id, test_prompt: testPrompt.value })
|
||||
testResult.value = res.data
|
||||
} finally { testing.value = false }
|
||||
}
|
||||
|
||||
const getProviderLabel = (provider) => {
|
||||
const map = { openai: 'OpenAI', zhipu: '智谱 AI', baidu: '百度文心', aliyun: '阿里通义' }
|
||||
return map[provider] || provider
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-models {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.models-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; }
|
||||
.model-card {
|
||||
background: var(--color-bg-card); border: 1px solid var(--color-border);
|
||||
border-radius: 12px; padding: 18px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.model-card.is-default { border-color: var(--color-accent-green); box-shadow: 0 0 12px rgba(63,185,80,0.15); }
|
||||
.model-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.model-name { display: flex; align-items: center; gap: 8px; }
|
||||
.provider-badge { font-size: 11px; padding: 2px 8px; border-radius: 12px; font-weight: 600; }
|
||||
.provider-badge.openai { background: rgba(16,163,127,0.15); color: #10a37f; }
|
||||
.provider-badge.zhipu { background: rgba(88,166,255,0.15); color: var(--color-accent); }
|
||||
.provider-badge.wenxin { background: rgba(240,136,62,0.15); color: var(--color-accent-orange); }
|
||||
.provider-badge.qianwen { background: rgba(210,153,34,0.15); color: var(--color-accent-yellow); }
|
||||
.provider-badge.local { background: rgba(188,140,255,0.15); color: var(--color-accent-purple); }
|
||||
.model-title { font-size: 15px; font-weight: 600; }
|
||||
.model-meta { display: flex; flex-wrap: wrap; gap: 10px; font-size: 12px; color: var(--color-text-muted); margin-bottom: 14px; }
|
||||
.model-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.test-result { margin-top: 16px; }
|
||||
.result-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.result-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
|
||||
.empty-state { grid-column: 1/-1; padding: 40px; }
|
||||
</style>
|
||||
|
||||
+250
-291
@@ -1,319 +1,278 @@
|
||||
<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 class="page-container dashboard">
|
||||
<!-- Top stat cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card" v-for="s in statCards" :key="s.label">
|
||||
<div class="stat-icon" :style="{ background: s.bg }">
|
||||
<el-icon :style="{ color: s.color, fontSize: '20px' }">
|
||||
<component :is="s.icon" />
|
||||
</el-icon>
|
||||
</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) }}
|
||||
<div class="stat-body">
|
||||
<div class="stat-value" :style="{ color: s.color }">{{ s.value }}</div>
|
||||
<div class="stat-label">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today interaction summary -->
|
||||
<div class="section-row">
|
||||
<el-card class="today-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>今日互动统计</span>
|
||||
<el-tag type="info" size="small">{{ todayDate }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="interact-bars">
|
||||
<div class="interact-item" v-for="it in interactTypes" :key="it.key">
|
||||
<div class="interact-label">{{ it.label }}</div>
|
||||
<el-progress
|
||||
:percentage="getPercent(it.key)"
|
||||
:color="it.color"
|
||||
:stroke-width="10"
|
||||
:show-text="false"
|
||||
class="interact-bar"
|
||||
/>
|
||||
<div class="interact-count" :style="{ color: it.color }">
|
||||
{{ todayStats[it.key] || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="system-card" shadow="never">
|
||||
<template #header><span>系统运行状态</span></template>
|
||||
<div class="sys-items">
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">运行时长</span>
|
||||
<span class="sys-val">{{ systemStatus.uptime || '--' }}</span>
|
||||
</div>
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">在线用户</span>
|
||||
<span class="sys-val accent-green">{{ onlineUsers }}</span>
|
||||
</div>
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">今日Token消耗</span>
|
||||
<span class="sys-val accent-blue">{{ tokenStats.today_used?.toLocaleString() || 0 }}</span>
|
||||
</div>
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">Token剩余</span>
|
||||
<span class="sys-val" :class="tokenLow ? 'accent-red' : 'accent-green'">
|
||||
{{ tokenStats.remaining?.toLocaleString() || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">今日AI调用</span>
|
||||
<span class="sys-val">{{ tokenStats.today_calls || 0 }} 次</span>
|
||||
</div>
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">调度器状态</span>
|
||||
<el-tag :type="systemStatus.scheduler_enabled ? 'success' : 'warning'" size="small">
|
||||
{{ systemStatus.scheduler_enabled ? '运行中' : '已暂停' }}
|
||||
</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>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="tokenPercent"
|
||||
:color="tokenLow ? '#f85149' : '#58a6ff'"
|
||||
:stroke-width="8"
|
||||
style="margin-top: 16px;"
|
||||
>
|
||||
<span style="font-size:11px;color:var(--color-text-muted)">Token使用率 {{ tokenPercent }}%</span>
|
||||
</el-progress>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Charts row -->
|
||||
<div class="charts-row">
|
||||
<el-card class="chart-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>近30天 Token消耗趋势</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button size="small" @click="loadTokenTrend(30)">30天</el-button>
|
||||
<el-button size="small" @click="loadTokenTrend(7)">7天</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="dailyChart" class="chart-box"></div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="chart-card" shadow="never">
|
||||
<template #header><span>近12个月 Token消耗</span></template>
|
||||
<div ref="monthlyChart" class="chart-box"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { dashboardApi } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { getDashboard, getTokenTrend, getMonthlyTokenTrend } from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const stats = ref({})
|
||||
const dailyChartRef = ref(null)
|
||||
const monthlyChartRef = ref(null)
|
||||
let dailyChart = null
|
||||
let monthlyChart = null
|
||||
const todayDate = dayjs().format('YYYY-MM-DD')
|
||||
|
||||
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)
|
||||
// data
|
||||
const userStats = ref({})
|
||||
const todayStats = ref({})
|
||||
const tokenStats = ref({})
|
||||
const systemStatus = ref({})
|
||||
const onlineUsers = ref(0)
|
||||
|
||||
const dailyChart = ref(null)
|
||||
const monthlyChart = ref(null)
|
||||
let dailyChartInst = null
|
||||
let monthlyChartInst = null
|
||||
|
||||
const interactTypes = [
|
||||
{ key: 'comment', label: '评论', color: '#58a6ff' },
|
||||
{ key: 'reply', label: '回复', color: '#bc8cff' },
|
||||
{ key: 'like', label: '点赞', color: '#3fb950' },
|
||||
{ key: 'collect', label: '收藏', color: '#f0883e' },
|
||||
{ key: 'forward', label: '转发', color: '#d29922' },
|
||||
]
|
||||
|
||||
const maxInteract = computed(() => {
|
||||
const vals = interactTypes.map(t => todayStats.value[t.key] || 0)
|
||||
return Math.max(...vals, 1)
|
||||
})
|
||||
|
||||
const recentInteractions = computed(() => {
|
||||
return stats.value.recent_interactions || []
|
||||
const getPercent = (key) => Math.round(((todayStats.value[key] || 0) / maxInteract.value) * 100)
|
||||
const tokenPercent = computed(() => {
|
||||
const used = tokenStats.value.today_used || 0
|
||||
const limit = tokenStats.value.daily_limit || 1
|
||||
return Math.min(Math.round((used / limit) * 100), 100)
|
||||
})
|
||||
const tokenLow = computed(() => tokenPercent.value > 80)
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
const statCards = computed(() => [
|
||||
{ label: '用户总数', value: userStats.value.total || 0, icon: 'User', color: '#58a6ff', bg: 'rgba(88,166,255,0.1)' },
|
||||
{ label: '正常启用', value: userStats.value.normal || 0, icon: 'CircleCheck', color: '#3fb950', bg: 'rgba(63,185,80,0.1)' },
|
||||
{ label: '当前在线', value: onlineUsers.value, icon: 'Connection', color: '#bc8cff', bg: 'rgba(188,140,255,0.1)' },
|
||||
{ label: '今日互动', value: todayStats.value.total || 0, icon: 'ChatDotRound', color: '#f0883e', bg: 'rgba(240,136,62,0.1)' },
|
||||
{ label: '登录失效', value: userStats.value.abnormal || 0, icon: 'Warning', color: '#d29922', bg: 'rgba(210,153,34,0.1)' },
|
||||
{ label: '封禁用户', value: userStats.value.banned || 0, icon: 'CircleClose', color: '#f85149', bg: 'rgba(248,81,73,0.1)' },
|
||||
])
|
||||
|
||||
async function loadDashboard() {
|
||||
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 res = await getDashboard()
|
||||
const d = res.data
|
||||
userStats.value = d.user_stats || {}
|
||||
todayStats.value = d.today_interactions || {}
|
||||
tokenStats.value = d.token_stats || {}
|
||||
systemStatus.value = d.system_status || {}
|
||||
onlineUsers.value = d.online_users || 0
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
loadData()
|
||||
ElMessage.success('数据已刷新')
|
||||
async function loadTokenTrend(days = 30) {
|
||||
try {
|
||||
const res = await getTokenTrend(days)
|
||||
const data = res.data || []
|
||||
if (!dailyChartInst) return
|
||||
dailyChartInst.setOption({
|
||||
tooltip: { trigger: 'axis', backgroundColor: '#1c2128', borderColor: '#30363d', textStyle: { color: '#e6edf3' } },
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: { type: 'category', data: data.map(d => d.date.slice(5)), axisLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||
yAxis: { type: 'value', axisLine: { lineStyle: { color: '#30363d' } }, splitLine: { lineStyle: { color: '#21262d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||
series: [{
|
||||
type: 'line', data: data.map(d => d.tokens),
|
||||
smooth: true, symbol: 'none',
|
||||
lineStyle: { color: '#58a6ff', width: 2 },
|
||||
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(88,166,255,0.3)' }, { offset: 1, color: 'rgba(88,166,255,0)' }] } },
|
||||
}]
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 渲染每日 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'
|
||||
}
|
||||
}]
|
||||
})
|
||||
async function loadMonthlyTrend() {
|
||||
try {
|
||||
const res = await getMonthlyTokenTrend()
|
||||
const data = res.data || []
|
||||
if (!monthlyChartInst) return
|
||||
monthlyChartInst.setOption({
|
||||
tooltip: { trigger: 'axis', backgroundColor: '#1c2128', borderColor: '#30363d', textStyle: { color: '#e6edf3' } },
|
||||
grid: { left: 55, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: { type: 'category', data: data.map(d => d.month), axisLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||
yAxis: { type: 'value', axisLine: { lineStyle: { color: '#30363d' } }, splitLine: { lineStyle: { color: '#21262d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||
series: [{
|
||||
type: 'bar', data: data.map(d => d.tokens),
|
||||
itemStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: '#bc8cff' }, { offset: 1, color: 'rgba(188,140,255,0.2)' }] }, borderRadius: [4, 4, 0, 0] },
|
||||
}]
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 渲染每月 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 handleResize = () => { dailyChartInst?.resize(); monthlyChartInst?.resize() }
|
||||
|
||||
// 工具函数
|
||||
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()
|
||||
})
|
||||
onMounted(async () => {
|
||||
await loadDashboard()
|
||||
dailyChartInst = echarts.init(dailyChart.value, null, { renderer: 'svg' })
|
||||
monthlyChartInst = echarts.init(monthlyChart.value, null, { renderer: 'svg' })
|
||||
await loadTokenTrend(30)
|
||||
await loadMonthlyTrend()
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('page-refresh', loadDashboard)
|
||||
// 每30秒自动刷新数据看板
|
||||
_dashTimer = setInterval(loadDashboard, 30000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('page-refresh', loadDashboard)
|
||||
if (_dashTimer) clearInterval(_dashTimer)
|
||||
dailyChartInst?.dispose()
|
||||
monthlyChartInst?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
.dashboard { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 1400px) { .stats-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
|
||||
.stat-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.stat-card:hover { border-color: var(--color-accent); }
|
||||
.stat-icon { width: 44px; height: 44px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; line-height: 1; }
|
||||
.stat-label { font-size: 12px; color: var(--color-text-muted); margin-top: 4px; }
|
||||
|
||||
.section-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
:deep(.el-card) { background: var(--color-bg-card) !important; border-color: var(--color-border) !important; }
|
||||
:deep(.el-card__header) { border-bottom: 1px solid var(--color-border); padding: 14px 18px; font-size: 14px; font-weight: 600; color: var(--color-text); }
|
||||
:deep(.el-card__body) { padding: 18px; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
|
||||
.interact-bars { display: flex; flex-direction: column; gap: 12px; }
|
||||
.interact-item { display: flex; align-items: center; gap: 10px; }
|
||||
.interact-label { width: 32px; font-size: 12px; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.interact-bar { flex: 1; }
|
||||
.interact-count { width: 36px; text-align: right; font-size: 13px; font-weight: 600; flex-shrink: 0; }
|
||||
|
||||
.sys-items { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.sys-item { display: flex; flex-direction: column; gap: 3px; }
|
||||
.sys-key { font-size: 11px; color: var(--color-text-muted); }
|
||||
.sys-val { font-size: 15px; font-weight: 600; color: var(--color-text); }
|
||||
.accent-green { color: var(--color-accent-green) !important; }
|
||||
.accent-blue { color: var(--color-accent) !important; }
|
||||
.accent-red { color: var(--color-accent-red) !important; }
|
||||
|
||||
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.chart-box { height: 220px; }
|
||||
</style>
|
||||
|
||||
@@ -1,109 +1,214 @@
|
||||
<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 class="page-container">
|
||||
<div class="page-header">
|
||||
<span class="page-title">互动记录</span>
|
||||
<el-button size="small" @click="handleExport">
|
||||
<el-icon><Download /></el-icon> 导出
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="filters.keyword" placeholder="搜索用户/文章" clearable @change="load" style="width:200px">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filters.interact_type" placeholder="互动类型" clearable @change="load" style="width:120px">
|
||||
<el-option v-for="(l,v) in typeMap" :key="v" :label="l" :value="v" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" placeholder="状态" clearable @change="load" style="width:110px">
|
||||
<el-option label="执行中" :value="0"/><el-option label="成功" :value="1"/><el-option label="失败" :value="2"/>
|
||||
</el-select>
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" @change="onDateChange" style="width:240px" />
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="records" v-loading="loading" stripe class="data-table">
|
||||
<el-table-column label="用户" width="140">
|
||||
<template #default="{ row }">
|
||||
<div style="font-size:13px;font-weight:600">{{ row.user_nickname }}</div>
|
||||
<div style="font-size:11px;color:var(--color-text-muted)">{{ row.user_account }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文章" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
:href="row.article_url || '#'"
|
||||
target="_blank" type="primary" style="font-size:13px">{{ row.article_title || row.article_id || '--' }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="typeTagType[row.interact_type]" size="small">{{ row.interact_type_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内容" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.content || '--' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Token" width="75" align="center">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:var(--color-accent)">{{ row.token_consumed || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType[row.status]" size="small">{{ row.status_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行时间" width="145">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.executed_at ? row.executed_at.replace('T',' ').replace('+08:00','').replace('+00:00','') : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 1 && row.interact_type !== 'forward' && row.interact_type !== 'read'"
|
||||
size="small" type="danger" plain
|
||||
:loading="cancellingId === row.id"
|
||||
@click="handleCancel(row)">取消</el-button>
|
||||
<el-button v-if="row.status === 2 && row.retry_count < 3" size="small" type="warning" @click="retry(row)">重试</el-button>
|
||||
<el-tooltip v-else-if="row.status === 2" content="已超过最大重试次数" placement="top">
|
||||
<el-button size="small" disabled>重试</el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" :page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="load" style="margin-top:16px;justify-content:flex-end;display:flex"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { interactionApi } from '@/api'
|
||||
import { ref, reactive, onMounted, onUnmounted, nextTick, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getInteractions, retryInteraction, exportInteractions, cancelInteraction , getSystemConfigs } from '@/api'
|
||||
|
||||
const records = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const interactionList = ref([])
|
||||
const articleBaseDomain = ref('') // 从系统配置动态读取
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const dateRange = ref(null)
|
||||
const filters = reactive({ keyword: '', interact_type: null, status: null, start_date: '', end_date: '' })
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
const typeMap = { comment: '评论', reply: '回复', like: '点赞', collect: '收藏', forward: '转发' }
|
||||
const typeTagType = { comment: '', reply: 'info', like: 'success', collect: 'warning', forward: 'danger' }
|
||||
const statusTagType = { 0: 'info', 1: 'success', 2: 'danger' }
|
||||
|
||||
const loadData = async () => {
|
||||
function onDateChange(v) {
|
||||
filters.start_date = v?.[0] || ''; filters.end_date = v?.[1] || ''
|
||||
load()
|
||||
}
|
||||
|
||||
// 构造文章详情页 URL(H5 分享页面)
|
||||
// 用 computed 确保 articleBaseDomain 变化时响应式更新
|
||||
const articleUrlBase = computed(() => articleBaseDomain.value)
|
||||
|
||||
function getArticleUrl(articleId) {
|
||||
if (!articleId || !articleUrlBase.value) return '#'
|
||||
return `${articleUrlBase.value}/huihui-h5/#/news/share?id=${articleId}&login=no`
|
||||
}
|
||||
|
||||
// 从系统配置获取业务接口域名,用于拼接文章链接
|
||||
async function loadArticleDomain() {
|
||||
try {
|
||||
const res = await getSystemConfigs()
|
||||
// data 是 {key: {value, type, desc}} 格式的字典
|
||||
const cfgData = res.data || {}
|
||||
const bizUrl = cfgData['news_platform_base_url']?.value || ''
|
||||
if (bizUrl) {
|
||||
// 从 https://99hui.com/api/huihuibusiness 提取 https://99hui.com
|
||||
const parts = bizUrl.split('/')
|
||||
if (parts.length >= 3) {
|
||||
articleBaseDomain.value = parts[0] + '//' + parts[2]
|
||||
}
|
||||
}
|
||||
} catch(e) { console.error('loadArticleDomain error:', e) }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await interactionApi.getList({
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.pageSize
|
||||
const res = await getInteractions({ page: page.value, page_size: pageSize.value, ...filters })
|
||||
const items = res.data?.items || []
|
||||
// 直接拼接文章 URL,避免响应式追踪问题
|
||||
items.forEach(item => {
|
||||
if (item.article_id && articleBaseDomain.value) {
|
||||
item.article_url = `${articleBaseDomain.value}/huihui-h5/#/news/share?id=${item.article_id}&login=no`
|
||||
} else {
|
||||
item.article_url = ''
|
||||
}
|
||||
})
|
||||
interactionList.value = res.items || []
|
||||
pagination.value.total = res.total || 0
|
||||
} catch (error) {
|
||||
console.error('Load interactions error:', error)
|
||||
records.value = items
|
||||
total.value = res.data?.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(filters, { keyword: '', interact_type: null, status: null, start_date: '', end_date: '' })
|
||||
dateRange.value = null; load()
|
||||
}
|
||||
|
||||
const cancellingId = ref(null)
|
||||
|
||||
async function handleCancel(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认取消「${row.interact_type === 'comment' ? '评论' : row.interact_type === 'like' ? '点赞' : '收藏'}」互动?`,
|
||||
'取消互动', { confirmButtonText: '确认取消', cancelButtonText: '再想想', type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
cancellingId.value = row.id
|
||||
try {
|
||||
const res = await cancelInteraction(row.id)
|
||||
if (res.code === 200 || res.code === 0) {
|
||||
ElMessage.success('取消成功')
|
||||
await loadInteractions()
|
||||
} else {
|
||||
ElMessage.error(res.message || '取消失败')
|
||||
}
|
||||
} catch(e) {
|
||||
ElMessage.error('取消失败:' + (e.message || '未知错误'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
cancellingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type) => {
|
||||
const map = { comment: '评论', reply: '回复', like: '点赞', favorite: '收藏', share: '转发' }
|
||||
return map[type] || type
|
||||
async function retry(row) {
|
||||
await retryInteraction(row.id)
|
||||
ElMessage.success('重试已触发')
|
||||
load()
|
||||
}
|
||||
|
||||
const getTypeTag = (type) => {
|
||||
const map = { comment: 'primary', reply: 'success', like: 'warning', favorite: 'info', share: 'danger' }
|
||||
return map[type] || ''
|
||||
async function handleExport() {
|
||||
const res = await exportInteractions(filters)
|
||||
const url = URL.createObjectURL(new Blob([res]))
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'interactions.xlsx'; a.click()
|
||||
}
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
const map = { pending: '待执行', success: '成功', failed: '失败', retrying: '重试中' }
|
||||
return map[status] || status
|
||||
}
|
||||
let _autoRefreshTimer = null
|
||||
|
||||
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()
|
||||
onMounted(async () => {
|
||||
await loadArticleDomain() // 先等域名加载完
|
||||
load()
|
||||
window.addEventListener('page-refresh', load)
|
||||
// 每30秒自动刷新互动记录
|
||||
_autoRefreshTimer = setInterval(load, 30000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('page-refresh', load)
|
||||
if (_autoRefreshTimer) clearInterval(_autoRefreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.interactions {
|
||||
padding: 20px;
|
||||
}
|
||||
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.data-table { border-radius: 8px; overflow: hidden; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<span class="page-title">日志管理</span>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="log-tabs">
|
||||
<!-- Login logs -->
|
||||
<el-tab-pane label="登录日志" name="login">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="loginFilter.action" placeholder="操作类型" clearable @change="loadLoginLogs" style="width:130px">
|
||||
<el-option label="登录" value="login"/>
|
||||
<el-option label="登出" value="logout"/>
|
||||
<el-option label="刷新" value="refresh"/>
|
||||
<el-option label="失败" value="fail"/>
|
||||
</el-select>
|
||||
<el-button @click="loadLoginLogs"><el-icon><Refresh /></el-icon></el-button>
|
||||
</div>
|
||||
<el-table :data="loginLogs" v-loading="loginLoading" stripe class="data-table">
|
||||
<el-table-column label="用户账号" prop="user_account" width="180" />
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="actionType[row.action] || 'info'" size="small">{{ actionLabel[row.action] || row.action }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会话ID" prop="session_id" show-overflow-tooltip />
|
||||
<el-table-column label="失败原因" prop="error_msg" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="color:var(--color-accent-red);font-size:12px">{{ row.error_msg || '--' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.created_at ? row.created_at.replace('T',' ').replace('+08:00','').replace('+00:00','').slice(0,16) : '--' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="loginPage" :page-size="50" :total="loginTotal"
|
||||
layout="total, prev, pager, next" @current-change="loadLoginLogs" style="margin-top:12px;display:flex;justify-content:flex-end" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Log files -->
|
||||
<el-tab-pane label="日志文件" name="files">
|
||||
<div style="display:flex;gap:16px;height:600px">
|
||||
<!-- File list -->
|
||||
<div class="file-list">
|
||||
<div class="file-list-header">日志文件</div>
|
||||
<div
|
||||
v-for="f in logFiles" :key="f.name"
|
||||
class="file-item" :class="{ active: selectedFile === f.name }"
|
||||
@click="openFile(f.name)"
|
||||
>
|
||||
<el-icon style="flex-shrink:0"><Document /></el-icon>
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ f.name }}</div>
|
||||
<div class="file-size">{{ f.size_kb }} KB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!logFiles.length" style="padding:20px;color:var(--color-text-muted);font-size:13px;text-align:center">暂无日志文件</div>
|
||||
</div>
|
||||
|
||||
<!-- File content -->
|
||||
<div class="file-content" v-if="selectedFile">
|
||||
<div class="file-toolbar">
|
||||
<span style="font-size:13px;font-weight:600">{{ selectedFile }}</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-select v-model="tailLines" style="width:100px" @change="loadFileContent">
|
||||
<el-option label="最后100行" :value="100"/>
|
||||
<el-option label="最后500行" :value="500"/>
|
||||
<el-option label="最后1000行" :value="1000"/>
|
||||
</el-select>
|
||||
<el-button size="small" @click="loadFileContent"><el-icon><Refresh /></el-icon></el-button>
|
||||
<el-button size="small" type="primary" @click="downloadFile">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-content" ref="logContentRef">
|
||||
<div v-for="(line, i) in fileLines" :key="i" class="log-line" :class="getLineClass(line)">{{ line }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="file-empty">
|
||||
<el-empty description="选择左侧日志文件查看内容" />
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, nextTick, onMounted } from 'vue'
|
||||
import { getLoginLogs, getLogFiles, tailLogFile } from '@/api'
|
||||
|
||||
const activeTab = ref('login')
|
||||
const loginLogs = ref([])
|
||||
const loginLoading = ref(false)
|
||||
const loginPage = ref(1)
|
||||
const loginTotal = ref(0)
|
||||
const loginFilter = reactive({ action: '' })
|
||||
|
||||
const logFiles = ref([])
|
||||
const selectedFile = ref('')
|
||||
const fileLines = ref([])
|
||||
const tailLines = ref(100)
|
||||
const logContentRef = ref()
|
||||
|
||||
const actionLabel = { login: '登录', logout: '登出', refresh: '刷新', fail: '失败' }
|
||||
const actionType = { login: 'success', logout: 'info', refresh: 'warning', fail: 'danger' }
|
||||
|
||||
function getLineClass(line) {
|
||||
if (line.includes('ERROR') || line.includes('error')) return 'line-error'
|
||||
if (line.includes('WARNING') || line.includes('WARN')) return 'line-warn'
|
||||
if (line.includes('INFO')) return 'line-info'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function loadLoginLogs() {
|
||||
loginLoading.value = true
|
||||
try {
|
||||
const res = await getLoginLogs({ page: loginPage.value, page_size: 50, action: loginFilter.action || undefined })
|
||||
loginLogs.value = res.data?.items || []
|
||||
loginTotal.value = res.data?.total || 0
|
||||
} finally { loginLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadLogFiles() {
|
||||
const res = await getLogFiles()
|
||||
logFiles.value = res.data || []
|
||||
}
|
||||
|
||||
async function openFile(name) {
|
||||
selectedFile.value = name
|
||||
await loadFileContent()
|
||||
}
|
||||
|
||||
async function loadFileContent() {
|
||||
if (!selectedFile.value) return
|
||||
const res = await tailLogFile(selectedFile.value, tailLines.value)
|
||||
fileLines.value = (res.data?.lines || []).map(l => l.replace(/\n$/, ''))
|
||||
await nextTick()
|
||||
if (logContentRef.value) logContentRef.value.scrollTop = logContentRef.value.scrollHeight
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
window.open(`/api/logs/files/${selectedFile.value}/download`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => { loadLoginLogs(); loadLogFiles() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.data-table { border-radius: 8px; }
|
||||
:deep(.log-tabs .el-tabs__header) { margin-bottom: 16px; }
|
||||
:deep(.el-tabs__item) { color: var(--color-text-muted) !important; }
|
||||
:deep(.el-tabs__item.is-active) { color: var(--color-accent) !important; }
|
||||
|
||||
.file-list {
|
||||
width: 220px; flex-shrink: 0;
|
||||
background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.file-list-header { padding: 12px 14px; font-size: 12px; font-weight: 600; color: var(--color-text-muted); border-bottom: 1px solid var(--color-border); }
|
||||
.file-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px; cursor: pointer; transition: background 0.15s;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.file-item:hover { background: rgba(88,166,255,0.06); }
|
||||
.file-item.active { background: rgba(88,166,255,0.12); color: var(--color-accent); }
|
||||
.file-name { font-size: 12px; font-weight: 500; }
|
||||
.file-size { font-size: 11px; color: var(--color-text-muted); }
|
||||
|
||||
.file-content {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden;
|
||||
}
|
||||
.file-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--color-border); flex-shrink: 0;
|
||||
}
|
||||
.log-content { flex: 1; overflow-y: auto; padding: 10px 14px; font-family: 'Consolas','Monaco',monospace; font-size: 12px; line-height: 1.6; }
|
||||
.log-line { padding: 1px 0; color: var(--color-text-muted); white-space: pre-wrap; word-break: break-all; }
|
||||
.line-error { color: var(--color-accent-red); }
|
||||
.line-warn { color: var(--color-accent-orange); }
|
||||
.line-info { color: var(--color-text); }
|
||||
.file-empty { flex: 1; display: flex; align-items: center; justify-content: center; background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<span class="page-title">调度设置</span>
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<el-tag :type="schedulerEnabled ? 'success' : 'warning'" size="default">
|
||||
{{ schedulerEnabled ? '调度器运行中' : '调度器已暂停' }}
|
||||
</el-tag>
|
||||
<el-button :type="schedulerEnabled ? 'warning' : 'success'" @click="toggleScheduler">
|
||||
{{ schedulerEnabled ? '暂停调度' : '启动调度' }}
|
||||
</el-button>
|
||||
<el-button type="warning" @click="runNow" :loading="runningNow">
|
||||
⚡ 立即执行一次互动
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="resetSessions">重置所有会话</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form :model="configs" label-width="160px" v-loading="loading" class="settings-form">
|
||||
<!-- Time settings -->
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header><span>互动时间配置</span></template>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="互动开始时间">
|
||||
<el-time-select v-model="configs.interact_time_start" :start="'00:00'" :end="'23:30'" :step="'00:30'" style="width:140px" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="互动结束时间">
|
||||
<el-time-select v-model="configs.interact_time_end" :start="'00:30'" :end="'23:59'" :step="'00:30'" style="width:140px" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最小互动间隔(秒)">
|
||||
<el-input-number v-model.number="configs.interact_interval_min" :min="60" :max="3600" :step="60" style="width:160px" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大互动间隔(秒)">
|
||||
<el-input-number v-model.number="configs.interact_interval_max" :min="120" :max="7200" :step="60" style="width:160px" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="并发登录用户上限">
|
||||
<el-input-number v-model.number="configs.max_concurrent_users" :min="0" :max="200" style="width:160px" />
|
||||
<span class="hint">同时进行互动的最大用户数,<b style="color:#58a6ff">0 = 无上限</b></span>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<!-- Token quota -->
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header><span>Token配额管控</span></template>
|
||||
<el-form-item label="每日Token上限">
|
||||
<el-input-number v-model.number="configs.daily_token_limit" :min="1000" :max="10000000" :step="10000" style="width:200px" />
|
||||
<span class="hint">超过上限自动暂停AI任务,次日零点重置</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前使用情况">
|
||||
<el-progress :percentage="tokenPercent" :color="tokenPercent > 80 ? '#f85149' : '#58a6ff'" style="width:300px" />
|
||||
<span class="hint">{{ todayUsed.toLocaleString() }} / {{ (configs.daily_token_limit || 0).toLocaleString() }}</span>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<!-- Interaction probabilities -->
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header><span>互动概率配置(0-1之间)</span></template>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8" v-for="item in probItems" :key="item.key">
|
||||
<el-form-item :label="item.label">
|
||||
<el-input-number
|
||||
v-model.number="configs[item.key]"
|
||||
:min="0" :max="1" :step="0.05" :precision="2"
|
||||
style="width:130px"
|
||||
/>
|
||||
<span class="hint">{{ (configs[item.key] * 100).toFixed(0) }}%</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- News platform config -->
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<span>目标平台接口配置</span>
|
||||
<el-tag type="warning" size="small" style="margin-left:8px">必须正确配置才能登录互动</el-tag>
|
||||
</template>
|
||||
<el-form-item label="业务接口地址">
|
||||
<el-input v-model="configs.news_platform_base_url" placeholder="http://192.168.1.200:63120" style="width:360px" />
|
||||
<span class="hint">新闻/评论/点赞等业务接口</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="认证服务地址">
|
||||
<el-input v-model="configs.auth_base_url" placeholder="http://192.168.1.200:60040" style="width:360px" />
|
||||
<span class="hint">登录接口所在服务地址</span>
|
||||
</el-form-item>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="平台 appId">
|
||||
<el-input v-model="configs.platform_app_id" placeholder="客户端appId标识" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="平台 accessId">
|
||||
<el-input v-model="configs.platform_access_id" placeholder="客户端accessId" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="签名密钥">
|
||||
<el-input v-model="configs.platform_access_secret" type="password" show-password placeholder="accessSecret(可为空)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="clientCode">
|
||||
<el-input v-model="configs.platform_client_code" placeholder="登录用clientCode" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="组织Id (orgId)">
|
||||
<el-input v-model="configs.platform_org_id" placeholder="留空则登录后自动获取" style="width:240px" />
|
||||
<span class="hint">可留空,系统登录后自动从用户信息接口获取并保存</span>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<div style="padding-left:160px;margin-top:8px">
|
||||
<el-button type="primary" @click="save" :loading="saving" size="large">
|
||||
<el-icon><Check /></el-icon> 保存所有配置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSystemConfigs, updateSystemConfigs, toggleScheduler as apiToggle, resetAllSessions, getDashboard } from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const schedulerEnabled = ref(true)
|
||||
const todayUsed = ref(0)
|
||||
|
||||
const configs = reactive({
|
||||
interact_time_start: '08:00',
|
||||
interact_time_end: '22:00',
|
||||
interact_interval_min: 300,
|
||||
interact_interval_max: 1800,
|
||||
max_concurrent_users: 5,
|
||||
daily_token_limit: 100000,
|
||||
comment_probability: 0.4,
|
||||
reply_probability: 0.2,
|
||||
like_probability: 0.6,
|
||||
collect_probability: 0.3,
|
||||
forward_probability: 0.15,
|
||||
// 目标平台配置
|
||||
news_platform_base_url: 'http://192.168.1.200:63120',
|
||||
auth_base_url: 'http://192.168.1.200:60040',
|
||||
platform_app_id: '',
|
||||
platform_access_id: '',
|
||||
platform_access_secret: '',
|
||||
platform_client_code: '',
|
||||
platform_org_id: '',
|
||||
})
|
||||
|
||||
const probItems = [
|
||||
{ key: 'comment_probability', label: '评论概率' },
|
||||
{ key: 'reply_probability', label: '回复概率' },
|
||||
{ key: 'like_probability', label: '点赞概率' },
|
||||
{ key: 'collect_probability', label: '收藏概率' },
|
||||
{ key: 'forward_probability', label: '转发概率' },
|
||||
]
|
||||
|
||||
const tokenPercent = computed(() => {
|
||||
const limit = configs.daily_token_limit || 1
|
||||
return Math.min(Math.round((todayUsed.value / limit) * 100), 100)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [cfgRes, dashRes] = await Promise.all([getSystemConfigs(), getDashboard()])
|
||||
const cfg = cfgRes.data || {}
|
||||
Object.keys(configs).forEach(k => {
|
||||
if (cfg[k]) {
|
||||
const v = cfg[k].value
|
||||
const t = cfg[k].type
|
||||
configs[k] = t === 'int' ? parseInt(v) : (t === 'string' || !t) ? v : parseFloat(v)
|
||||
}
|
||||
})
|
||||
schedulerEnabled.value = cfg['scheduler_enabled']?.value === 'true'
|
||||
todayUsed.value = dashRes.data?.token_stats?.today_used || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {}
|
||||
Object.keys(configs).forEach(k => { payload[k] = String(configs[k]) })
|
||||
await updateSystemConfigs(payload)
|
||||
ElMessage.success('配置已保存')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function runNow() {
|
||||
runningNow.value = true
|
||||
try {
|
||||
const res = await runInteractionNow()
|
||||
const d = res.data || {}
|
||||
ElMessage.success(`已触发互动:${d.triggered || 0} 个用户`)
|
||||
} catch(e) {
|
||||
ElMessage.error('触发失败:' + (e.message || '未知'))
|
||||
} finally {
|
||||
runningNow.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleScheduler() {
|
||||
const newVal = !schedulerEnabled.value
|
||||
await apiToggle(newVal)
|
||||
schedulerEnabled.value = newVal
|
||||
ElMessage.success(newVal ? '调度器已启动' : '调度器已暂停')
|
||||
}
|
||||
|
||||
async function resetSessions() {
|
||||
await ElMessageBox.confirm('将重置所有用户的登录会话,用户需要重新登录才能互动,是否继续?', '警告', { type: 'warning' })
|
||||
await resetAllSessions()
|
||||
ElMessage.success('所有会话已重置')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-form { max-width: 900px; }
|
||||
.settings-card { margin-bottom: 16px; }
|
||||
:deep(.el-card__header) { border-bottom: 1px solid var(--color-border); padding: 14px 18px; font-size: 14px; font-weight: 600; color: var(--color-text); }
|
||||
:deep(.el-card__body) { padding: 20px 18px 12px; }
|
||||
:deep(.el-form-item) { margin-bottom: 16px; }
|
||||
.hint { font-size: 11px; color: var(--color-text-muted); margin-left: 8px; }
|
||||
</style>
|
||||
@@ -1,244 +0,0 @@
|
||||
<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>
|
||||
@@ -0,0 +1,614 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- Toolbar -->
|
||||
<div class="page-header">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span class="page-title">虚拟用户管理</span>
|
||||
<el-tag type="info">共 {{ total }} 个用户</el-tag>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button @click="downloadTemplate" size="small">
|
||||
<el-icon><Download /></el-icon> 下载模板
|
||||
</el-button>
|
||||
<el-upload :show-file-list="false" accept=".xlsx,.xls" :before-upload="handleImport">
|
||||
<el-tooltip content="仅账号和密码为必填,昵称为空时自动生成" placement="top">
|
||||
<el-button size="small" type="warning">
|
||||
<el-icon><Upload /></el-icon> 批量导入
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</el-upload>
|
||||
<el-button size="small" @click="handleExport">
|
||||
<el-icon><Download /></el-icon> 导出
|
||||
</el-button>
|
||||
<el-popconfirm title="将删除重复账号,只保留最早导入的一条,确认?" @confirm="handleDeduplicate" confirm-button-text="确认去重" cancel-button-text="取消">
|
||||
<template #reference>
|
||||
<el-button size="small" type="warning">
|
||||
<el-icon><CopyDocument /></el-icon> 去重
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
<el-popconfirm title="将对所有未登录/登录失效的用户执行登录,确认?" @confirm="handleLoginAll" confirm-button-text="确认" cancel-button-text="取消">
|
||||
<template #reference>
|
||||
<el-button size="small" type="success" :loading="loginAllLoading">
|
||||
<el-icon><Connection /></el-icon> 一键登录全部
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
<el-popconfirm title="确定要登出所有虚拟用户吗?" @confirm="handleLogoutAll">
|
||||
<template #reference>
|
||||
<el-button size="small" type="info" :loading="logoutAllLoading">
|
||||
<el-icon><SwitchButton /></el-icon> 一键登出全部
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
<el-tooltip content="从目标平台同步所有已登录用户的昵称/真实姓名/性别/头像" placement="top">
|
||||
<el-button size="small" type="info" :loading="syncLoading" @click="handleSyncAll">
|
||||
<el-icon><Refresh /></el-icon> 同步用户信息
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-button type="primary" size="small" @click="openCreateDialog">
|
||||
<el-icon><Plus /></el-icon> 新增用户
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="filters.keyword" placeholder="搜索昵称/账号" clearable @change="loadUsers" style="width:200px">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filters.status" placeholder="用户状态" clearable @change="loadUsers" style="width:130px">
|
||||
<el-option v-for="(label,val) in statusMap" :key="val" :label="label" :value="Number(val)" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.is_enabled" placeholder="启用状态" clearable @change="loadUsers" style="width:120px">
|
||||
<el-option label="已启用" :value="1" /><el-option label="已禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Batch actions -->
|
||||
<div v-if="selectedIds.length" class="batch-bar">
|
||||
<span class="batch-info">已选 {{ selectedIds.length }} 项</span>
|
||||
<el-button size="small" type="success" @click="batchAction('enable')">批量启用</el-button>
|
||||
<el-button size="small" type="warning" @click="batchAction('disable')">批量禁用</el-button>
|
||||
<el-button size="small" @click="batchAction('logout')">批量登出</el-button>
|
||||
<el-button size="small" type="danger" @click="batchAction('delete')">批量删除</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<el-table
|
||||
:data="users" v-loading="loading" :row-class-name="rowClassName"
|
||||
@selection-change="selectedIds = $event.map(r => r.id)"
|
||||
class="data-table"
|
||||
>
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column label="用户" width="200">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<el-avatar :size="36" :src="row.avatar_url || ''" style="background:#30363d;flex-shrink:0">
|
||||
{{ row.nickname?.[0] }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:13px">{{ row.nickname }}</div>
|
||||
<div style="font-size:11px;color:var(--color-text-muted)">{{ row.account }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType[row.status]" size="small">{{ row.status_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="活跃度" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="activityType[row.activity_level]" size="small" effect="plain">{{ row.activity_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="人格" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.personality" style="display:flex;gap:4px;flex-wrap:wrap">
|
||||
<el-tag size="small" effect="plain">{{ row.personality.character_type }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="warning">{{ row.personality.language_style }}</el-tag>
|
||||
<el-tag v-for="t in (row.personality.interest_tags||[]).slice(0,2)" :key="t" size="small" effect="plain" type="info">{{ t }}</el-tag>
|
||||
</div>
|
||||
<span v-else style="color:var(--color-text-muted);font-size:12px">未生成</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="今日评论" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="row.today_comment_count >= row.daily_comment_limit ? 'color:var(--color-accent-red)' : ''">
|
||||
{{ row.today_comment_count }}/{{ row.daily_comment_limit }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="累计互动" width="90" align="center" prop="total_interactions" />
|
||||
<el-table-column label="最后互动" width="145">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.last_interact_at ? new Date(row.last_interact_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}) : '--' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.is_enabled === 1" @change="toggleEnabled(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:nowrap;">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="success" v-if="row.status !== 2" @click="doLogin(row)">登录</el-button>
|
||||
<el-button size="small" type="warning" v-else @click="doLogout(row)">登出</el-button>
|
||||
<el-dropdown size="small" trigger="click">
|
||||
<el-button size="small">更多<el-icon style="margin-left:2px"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="openPersonality(row)">🧠 人格设置</el-dropdown-item>
|
||||
<el-dropdown-item @click="doDelete(row)" style="color:#f85149">🗑️ 删除用户</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" :page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="loadUsers" style="margin-top:16px;justify-content:flex-end;display:flex"
|
||||
/>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editUser ? '编辑用户' : '新增虚拟用户'" width="560px">
|
||||
<el-form :model="form" label-width="100px" :rules="rules" ref="formRef">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="form.nickname" placeholder="选填,留空自动生成" />
|
||||
</el-form-item>
|
||||
<el-form-item label="平台账号" prop="account">
|
||||
<el-input v-model="form.account" placeholder="新闻平台登录账号" :disabled="!!editUser" />
|
||||
</el-form-item>
|
||||
<el-form-item label="登录密码" :prop="editUser ? '' : 'password'">
|
||||
<el-input v-model="form.password" type="password" :placeholder="editUser ? '不修改请留空' : '登录密码'" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="头像">
|
||||
<div style="display:flex;gap:10px;align-items:flex-start;width:100%">
|
||||
<el-avatar :size="50" :src="form.avatar_url" style="flex-shrink:0">
|
||||
<span>{{ (form.nickname||'?')[0] }}</span>
|
||||
</el-avatar>
|
||||
<div style="flex:1">
|
||||
<el-input v-model="form.avatar_url" placeholder="头像图片链接" style="margin-bottom:6px" />
|
||||
<el-upload v-if="editUser" :show-file-list="false" :before-upload="handleAvatarUpload" accept="image/*">
|
||||
<el-button size="small" type="primary" plain :loading="avatarUploading">📷 上传头像</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="真实姓名">
|
||||
<el-input v-model="form.real_name" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="平台昵称">
|
||||
<el-input v-model="form.nickname" placeholder="同步到目标平台的昵称" />
|
||||
<div style="font-size:11px;color:var(--color-text-muted);margin-top:4px">昵称将同步到目标平台个人中心显示</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="性别">
|
||||
<el-radio-group v-model="form.sex">
|
||||
<el-radio :value="0">未知</el-radio>
|
||||
<el-radio :value="1">男</el-radio>
|
||||
<el-radio :value="2">女</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="个人简介">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="个人简介(可同步到平台)" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" placeholder="邮箱地址(可同步到平台)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="活跃度">
|
||||
<el-radio-group v-model="form.activity_level">
|
||||
<el-radio :value="0">低 (3-5次/天)</el-radio>
|
||||
<el-radio :value="1">中 (8-15次/天)</el-radio>
|
||||
<el-radio :value="2">高 (20-30次/天)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="评论上限">
|
||||
<el-input-number v-model="form.daily_comment_limit" :min="1" :max="100" />
|
||||
<span style="margin-left:8px;font-size:12px;color:var(--color-text-muted)">次/天</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitting">
|
||||
{{ editUser ? '保存' : '创建并生成人格' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Personality Dialog -->
|
||||
<el-dialog v-model="personalityVisible" title="AI人格管理" width="560px">
|
||||
<div v-if="currentPersonality" class="personality-panel">
|
||||
<div class="p-header">
|
||||
<span class="p-user">{{ currentUser?.nickname }}</span>
|
||||
<el-button size="small" type="primary" @click="regenPersonality" :loading="regenLoading">
|
||||
<el-icon><RefreshRight /></el-icon> 重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="p-grid">
|
||||
<div class="p-item"><span class="p-key">性格类型</span>
|
||||
<el-select v-model="currentPersonality.character_type" size="small" style="width:120px">
|
||||
<el-option v-for="c in characters" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="p-item"><span class="p-key">语言风格</span>
|
||||
<el-select v-model="currentPersonality.language_style" size="small" style="width:120px">
|
||||
<el-option v-for="s in langStyles" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="p-item"><span class="p-key">互动倾向</span>
|
||||
<el-select v-model="currentPersonality.interact_tendency" size="small" style="width:120px">
|
||||
<el-option v-for="t in tendencies" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="p-item"><span class="p-key">字数范围</span>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<el-input-number v-model="currentPersonality.word_count_min" :min="10" :max="200" size="small" style="width:90px" />
|
||||
<span>~</span>
|
||||
<el-input-number v-model="currentPersonality.word_count_max" :min="30" :max="500" size="small" style="width:90px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-item" style="margin-top:12px">
|
||||
<span class="p-key">兴趣偏好</span>
|
||||
<el-select v-model="currentPersonality.interest_tags" multiple size="small" style="width:100%">
|
||||
<el-option v-for="t in interestTags" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="p-item" style="margin-top:12px">
|
||||
<span class="p-key">人格描述</span>
|
||||
<el-input v-model="currentPersonality.personality_desc" type="textarea" :rows="3" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-empty">
|
||||
<el-empty description="尚未生成人格">
|
||||
<el-button type="primary" @click="regenPersonality">立即生成</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="personalityVisible = false">关闭</el-button>
|
||||
<el-button v-if="currentPersonality" type="primary" @click="savePersonality" :loading="savingPersonality">保存人格</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getUsers, createUser, updateUser, deleteUser, batchUserAction,
|
||||
loginUser, logoutUser, generatePersonality, updatePersonality,
|
||||
downloadTemplate as dlTemplate, importUsers, exportUsers, deduplicateUsers, loginAllUsers, syncAllProfiles,
|
||||
uploadAvatar
|
||||
} from '@/api'
|
||||
|
||||
const users = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const selectedIds = ref([])
|
||||
const filters = reactive({ keyword: '', status: null, is_enabled: null })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editUser = ref(null)
|
||||
const formRef = ref()
|
||||
const form = reactive({ nickname: '', account: '', password: '', avatar_url: '', activity_level: 1, daily_comment_limit: 10, remark: '' })
|
||||
const rules = {
|
||||
account: [{ required: true, message: '请输入平台账号(必填)' }],
|
||||
password: [{ required: true, min: 6, message: '密码至少6位(必填)' }],
|
||||
}
|
||||
|
||||
const personalityVisible = ref(false)
|
||||
const currentUser = ref(null)
|
||||
const currentPersonality = ref(null)
|
||||
const regenLoading = ref(false)
|
||||
const savingPersonality = ref(false)
|
||||
|
||||
const statusMap = { 0: '未登录', 1: '登录中', 2: '已登录', 3: '登录失效', 4: '封禁' }
|
||||
const statusType = { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'danger' }
|
||||
const activityType = { 0: 'info', 1: 'warning', 2: 'success' }
|
||||
const characters = ['开朗','内敛','毒舌','温和','理性','感性','幽默','严谨']
|
||||
const langStyles = ['严肃','幽默','文艺','吐槽','口语化','学术','简洁','丰富']
|
||||
const tendencies = ['爱评论','爱点赞','爱收藏','潜水','爱转发','爱回复']
|
||||
const interestTags = ['科技','财经','娱乐','体育','政治','文化','教育','医疗','汽车','房产','旅游','美食','军事','国际','环保','农业']
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUsers({ page: page.value, page_size: pageSize.value, ...filters })
|
||||
users.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(filters, { keyword: '', status: null, is_enabled: null })
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editUser.value = null
|
||||
Object.assign(form, { nickname: '', account: '', password: '', avatar_url: '', real_name: '', sex: 0, description: '', email: '', activity_level: 1, daily_comment_limit: 10, remark: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editUser.value = row
|
||||
Object.assign(form, { nickname: row.nickname, account: row.account, password: '', avatar_url: row.avatar_url || '', real_name: row.real_name || '', sex: row.sex ?? 0, description: row.description || '', email: row.email || '', activity_level: row.activity_level, daily_comment_limit: row.daily_comment_limit, remark: row.remark || '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editUser.value) {
|
||||
const data = { ...form }
|
||||
if (!data.password) delete data.password
|
||||
data.sync_to_platform = true // 自动同步到目标平台
|
||||
const res = await updateUser(editUser.value.id, data)
|
||||
if (res.code === 200 || res.code === 206) {
|
||||
// 局部刷新:只更新当前行数据,不刷新整个列表
|
||||
const idx = users.value.findIndex(u => u.id === editUser.value.id)
|
||||
if (idx !== -1) {
|
||||
const updated = { ...users.value[idx], ...{
|
||||
nickname: form.nickname || users.value[idx].nickname,
|
||||
avatar_url: form.avatar_url,
|
||||
real_name: form.real_name,
|
||||
sex: form.sex,
|
||||
activity_level: form.activity_level,
|
||||
daily_comment_limit: form.daily_comment_limit,
|
||||
remark: form.remark,
|
||||
}}
|
||||
users.value.splice(idx, 1, updated)
|
||||
}
|
||||
if (res.code === 206) {
|
||||
ElMessage.warning(res.message || '本地已保存,平台同步失败')
|
||||
} else {
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
await createUser(form)
|
||||
ElMessage.success('用户创建成功,AI人格已自动生成')
|
||||
loadUsers()
|
||||
}
|
||||
dialogVisible.value = false
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
|
||||
async function doDelete(row) {
|
||||
await ElMessageBox.confirm(`确认删除用户 "${row.nickname}"?`, '确认', { type: 'warning' })
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function batchAction(action) {
|
||||
if (action === 'delete') {
|
||||
await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 个用户?`, '危险操作', { type: 'warning' })
|
||||
}
|
||||
await batchUserAction({ user_ids: selectedIds.value, action })
|
||||
ElMessage.success('操作成功')
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function toggleEnabled(row) {
|
||||
await updateUser(row.id, { is_enabled: row.is_enabled ? 0 : 1 })
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function doLogin(row) {
|
||||
const loading = ElMessage({ message: `正在登录 ${row.nickname}...`, duration: 0 })
|
||||
try {
|
||||
await loginUser(row.id)
|
||||
loading.close()
|
||||
ElMessage.success('登录成功')
|
||||
loadUsers()
|
||||
} catch { loading.close() }
|
||||
}
|
||||
|
||||
async function doLogout(row) {
|
||||
await logoutUser(row.id)
|
||||
ElMessage.success('已登出')
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function openPersonality(row) {
|
||||
currentUser.value = row
|
||||
currentPersonality.value = row.personality ? { ...row.personality } : null
|
||||
personalityVisible.value = true
|
||||
}
|
||||
|
||||
async function regenPersonality() {
|
||||
regenLoading.value = true
|
||||
try {
|
||||
const res = await generatePersonality(currentUser.value.id)
|
||||
currentPersonality.value = res.data
|
||||
ElMessage.success('人格重新生成成功')
|
||||
loadUsers()
|
||||
} finally { regenLoading.value = false }
|
||||
}
|
||||
|
||||
async function savePersonality() {
|
||||
savingPersonality.value = true
|
||||
try {
|
||||
await updatePersonality(currentUser.value.id, currentPersonality.value)
|
||||
ElMessage.success('人格已保存')
|
||||
loadUsers()
|
||||
} finally { savingPersonality.value = false }
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
const res = await dlTemplate()
|
||||
const url = URL.createObjectURL(new Blob([res]))
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'import_template.xlsx'; a.click()
|
||||
}
|
||||
|
||||
async function handleImport(file) {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
// 显示进度提示
|
||||
const loading = ElMessage({
|
||||
message: '正在导入中,请稍候...',
|
||||
type: 'info',
|
||||
duration: 0,
|
||||
showClose: false,
|
||||
icon: 'Loading'
|
||||
})
|
||||
try {
|
||||
const res = await importUsers(fd)
|
||||
loading.close()
|
||||
const d = res.data || {}
|
||||
const success = d.success || 0
|
||||
const failed = d.failed || 0
|
||||
if (failed > 0 && success === 0) {
|
||||
ElMessage.error(`导入失败:${failed}条失败,请检查数据格式`)
|
||||
} else if (failed > 0) {
|
||||
ElMessage.warning(`导入完成:成功 ${success} 条,失败 ${failed} 条`)
|
||||
} else {
|
||||
ElMessage.success(`导入成功:共导入 ${success} 条用户`)
|
||||
}
|
||||
// 导入完成后自动刷新列表
|
||||
await loadUsers()
|
||||
} catch(e) {
|
||||
loading.close()
|
||||
ElMessage.error('导入失败:' + (e.message || '未知错误'))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const res = await exportUsers()
|
||||
const url = URL.createObjectURL(new Blob([res]))
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'users_export.xlsx'; a.click()
|
||||
}
|
||||
|
||||
const loginAllLoading = ref(false)
|
||||
const logoutAllLoading = ref(false)
|
||||
|
||||
const syncLoading = ref(false)
|
||||
const avatarUploading = ref(false)
|
||||
|
||||
async function handleAvatarUpload(file) {
|
||||
if (!editUser.value) return false
|
||||
avatarUploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('sync_to_platform', 'true') // 始终同步到平台
|
||||
const res = await uploadAvatar(editUser.value.id, formData)
|
||||
if (res.code === 200 || res.code === 0) {
|
||||
form.avatar_url = res.data?.avatar_url || ''
|
||||
ElMessage.success('头像上传成功')
|
||||
} else {
|
||||
ElMessage.error(res.message || '头像上传失败')
|
||||
}
|
||||
} catch(e) {
|
||||
ElMessage.error('上传失败:' + (e.message || '未知错误'))
|
||||
} finally {
|
||||
avatarUploading.value = false
|
||||
}
|
||||
return false // 阻止 el-upload 默认上传
|
||||
}
|
||||
|
||||
async function handleLogoutAll() {
|
||||
logoutAllLoading.value = true
|
||||
try {
|
||||
const res = await request.post('/users/logout-all')
|
||||
ElMessage.success(res.message || '已登出所有用户')
|
||||
loadUsers()
|
||||
} catch(e) {
|
||||
ElMessage.error('登出失败:' + (e.message || '未知错误'))
|
||||
} finally {
|
||||
logoutAllLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncAll() {
|
||||
syncLoading.value = true
|
||||
try {
|
||||
const res = await syncAllProfiles()
|
||||
const d = res.data || {}
|
||||
ElMessage.success(res.message || `同步完成:${d.synced} 个用户`)
|
||||
await loadUsers()
|
||||
} catch(e) {
|
||||
ElMessage.error('同步失败:' + (e.message || '未知错误'))
|
||||
} finally {
|
||||
syncLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoginAll() {
|
||||
loginAllLoading.value = true
|
||||
try {
|
||||
const res = await loginAllUsers()
|
||||
const d = res.data || {}
|
||||
ElMessage.success(res.message || `登录完成:成功 ${d.success} 个`)
|
||||
await loadUsers()
|
||||
} catch(e) {
|
||||
ElMessage.error('一键登录失败:' + (e.message || '未知错误'))
|
||||
} finally {
|
||||
loginAllLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeduplicate() {
|
||||
const res = await deduplicateUsers()
|
||||
ElMessage.success(res.message || '去重完成')
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
// 暗色主题行样式:深浅交替,避免白底
|
||||
function rowClassName({ rowIndex }) {
|
||||
return rowIndex % 2 === 0 ? 'row-dark' : 'row-darker'
|
||||
}
|
||||
|
||||
onMounted(() => { loadUsers(); window.addEventListener('page-refresh', loadUsers) })
|
||||
onUnmounted(() => window.removeEventListener('page-refresh', loadUsers))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.batch-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(88,166,255,0.08); border: 1px solid rgba(88,166,255,0.3); border-radius: 8px; margin-bottom: 10px; }
|
||||
.batch-info { font-size: 13px; color: var(--color-accent); margin-right: 4px; }
|
||||
.data-table { border-radius: 8px; overflow: hidden; }
|
||||
.personality-panel { }
|
||||
.p-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.p-user { font-size: 15px; font-weight: 600; }
|
||||
.p-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.p-item { display: flex; align-items: center; gap: 10px; }
|
||||
.p-key { font-size: 12px; color: var(--color-text-muted); white-space: nowrap; width: 60px; flex-shrink: 0; }
|
||||
.p-empty { padding: 20px 0; }
|
||||
|
||||
/* 暗色主题表格行样式 */
|
||||
:deep(.row-dark td) { background: var(--color-bg-card) !important; }
|
||||
:deep(.row-darker td) { background: var(--color-bg-secondary) !important; }
|
||||
:deep(.el-table__row:hover td) { background: rgba(88,166,255,0.08) !important; }
|
||||
:deep(.el-table__header-wrapper th) {
|
||||
background: var(--color-bg) !important;
|
||||
color: var(--color-text-muted) !important;
|
||||
border-bottom: 2px solid var(--color-border) !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
:deep(.el-table__body-wrapper) { background: transparent !important; }
|
||||
:deep(.el-table) { background: transparent !important; }
|
||||
</style>
|
||||
@@ -1,373 +0,0 @@
|
||||
<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>
|
||||
@@ -1,13 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
alias: { '@': resolve(__dirname, 'src') }
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/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 ""
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
echo "🔄 从 Gitea 拉取最新代码..."
|
||||
cd /opt/1panel/docker/compose/ai-virtual-news
|
||||
git pull origin main
|
||||
|
||||
echo "🔄 重启后端..."
|
||||
docker restart ai-virtual-backend
|
||||
sleep 15
|
||||
|
||||
echo "🔄 构建前端..."
|
||||
cd frontend
|
||||
rm -rf dist node_modules/.vite
|
||||
docker run --rm -v $(pwd):/app -w /app node:18-alpine sh -c "npm run build"
|
||||
docker cp dist/. ai-virtual-frontend:/usr/share/nginx/html/
|
||||
docker exec ai-virtual-frontend nginx -s reload
|
||||
cd ..
|
||||
|
||||
echo "✅ 更新完成!"
|
||||
docker logs --tail=3 ai-virtual-backend 2>&1
|
||||
Reference in New Issue
Block a user