Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07d4a21379 | ||
|
|
efc419e301 | ||
|
|
c748950402 | ||
|
|
f13d488f82 | ||
|
|
43f2ad9edb | ||
|
|
8e365e63e1 | ||
|
|
dbd668e451 | ||
|
|
190ec48d9c | ||
|
|
0cc43a0d8c | ||
|
|
43bb57eacf | ||
|
|
7327939b4d | ||
|
|
76f0730b57 | ||
|
|
6f6655e791 | ||
|
|
e3cce871b0 | ||
|
|
b7f8ac203b | ||
|
|
a5f7c86a4b | ||
|
|
6026b1279e | ||
|
|
57af1c7db1 | ||
|
|
bbbc97eb4a | ||
|
|
1da5f7f10d | ||
|
|
cc939b6298 | ||
|
|
e3bda469bb | ||
|
|
2ef58a44b8 | ||
|
|
2d9f26a6f0 | ||
|
|
501f548bcc | ||
|
|
cc8af846d5 | ||
|
|
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
+13
-3
@@ -1,3 +1,13 @@
|
||||
"""
|
||||
API 路由模块初始化
|
||||
"""
|
||||
"""API路由汇总"""
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import users, interactions, ai_models, dashboard, system, logs, avatars
|
||||
|
||||
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=["日志管理"])
|
||||
router.include_router(avatars.router, prefix="/avatars", 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(),
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"""数字分身管理 API 端点"""
|
||||
from typing import Optional
|
||||
import mimetypes
|
||||
import uuid
|
||||
from fastapi import APIRouter, Query, HTTPException, UploadFile, File
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.schemas import ApiResponse
|
||||
from app.services.avatar_service import avatar_service, get_session, is_available
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_avatars(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
keyword: str = Query(default=None),
|
||||
status: str = Query(default=None),
|
||||
):
|
||||
"""分页查询所有数字分身"""
|
||||
if not is_available():
|
||||
return ApiResponse(data={"total": 0, "page": page, "page_size": page_size, "items": []})
|
||||
db = get_session()
|
||||
try:
|
||||
total, items = avatar_service.list_avatars(
|
||||
db, page, page_size, keyword, status
|
||||
)
|
||||
return ApiResponse(
|
||||
data={
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": items,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{avatar_id}")
|
||||
def get_avatar(avatar_id: str):
|
||||
"""获取单个数字分身详情"""
|
||||
if not is_available():
|
||||
raise HTTPException(status_code=503, detail="数字分身数据库尚未初始化")
|
||||
db = get_session()
|
||||
try:
|
||||
data = avatar_service.get_avatar(db, avatar_id)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="数字分身不存在")
|
||||
return ApiResponse(data=data)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/{avatar_id}/status")
|
||||
def update_avatar_status(avatar_id: str, body: dict):
|
||||
"""更新数字分身状态(开机/关机)"""
|
||||
status = body.get("status")
|
||||
if status not in ("active", "inactive", "training"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="status 必须是 active、inactive 或 training",
|
||||
)
|
||||
if not is_available():
|
||||
raise HTTPException(status_code=503, detail="数字分身数据库尚未初始化")
|
||||
db = get_session()
|
||||
try:
|
||||
data = avatar_service.update_status(db, avatar_id, status)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="数字分身不存在")
|
||||
msg = "已开机" if status == "active" else "已关机"
|
||||
return ApiResponse(data=data, message=msg)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _upload_to_filecenter(file_bytes: bytes, filename: str) -> str:
|
||||
"""利用会会平台 filecenter 上传头像,返回 URL"""
|
||||
import httpx
|
||||
import hashlib
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 读取平台配置
|
||||
from sqlalchemy import select
|
||||
from app.models import SystemConfig
|
||||
result = await db.execute(select(SystemConfig))
|
||||
configs = {row.config_key: row.config_value for row in result.scalars().all()}
|
||||
|
||||
cfg = {
|
||||
"appId": configs.get("platform_app_id", ""),
|
||||
"accessId": configs.get("platform_access_id", ""),
|
||||
"accessSecret": configs.get("platform_access_secret", ""),
|
||||
}
|
||||
biz_url = configs.get("news_platform_base_url", "http://192.168.1.200:63120")
|
||||
|
||||
if not cfg["accessSecret"]:
|
||||
raise RuntimeError("平台 accessSecret 未配置")
|
||||
|
||||
# 构建签名参数
|
||||
nonce = str(random.random())[2:][: random.randint(8, 12)]
|
||||
timestamp = datetime.now().strftime("%Y%m%d%I%M%S")
|
||||
sign_params = {
|
||||
"appId": cfg["appId"],
|
||||
"accessId": cfg["accessId"],
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
"module": "userInfo",
|
||||
"service": "kccloud",
|
||||
}
|
||||
|
||||
# 计算签名
|
||||
keys = sorted(sign_params.keys())
|
||||
sign_parts = []
|
||||
for k in keys:
|
||||
v = sign_params.get(k)
|
||||
if v and v != "" and v != []:
|
||||
sign_parts.append(f"{k}={v}")
|
||||
sign_str = "&".join(sign_parts) + f"&accessSecret={cfg['accessSecret']}"
|
||||
signature = hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
# 构建 filecenter URL
|
||||
filecenter_url = biz_url.replace("/huihuibusiness", "/filecenter")
|
||||
if "/api/" in filecenter_url:
|
||||
filecenter_url = filecenter_url.split("/api/", 1)[0] + "/api/filecenter"
|
||||
else:
|
||||
filecenter_url = filecenter_url.rstrip("/") + "/filecenter"
|
||||
|
||||
# 确定 MIME 类型
|
||||
mime = mimetypes.guess_type(filename)[0] or "image/jpeg"
|
||||
files = {"file": (filename, file_bytes, mime)}
|
||||
|
||||
# 发送请求
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{filecenter_url}/fileUpload",
|
||||
files=files,
|
||||
data={**sign_params, "signature": signature},
|
||||
)
|
||||
d = r.json()
|
||||
if d.get("code") in [0, 200]:
|
||||
url = d.get("data") or d.get("url") or ""
|
||||
if isinstance(url, dict):
|
||||
url = url.get("url") or url.get("path") or ""
|
||||
if not url:
|
||||
raise RuntimeError(f"filecenter 返回空 URL: {d}")
|
||||
return url
|
||||
raise RuntimeError(f"filecenter 上传失败: {d.get('message', '未知错误')}")
|
||||
|
||||
|
||||
@router.post("/{avatar_id}/upload-photo")
|
||||
async def upload_avatar_photo(
|
||||
avatar_id: str,
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""上传数字分身头像(通过会会平台 filecenter)"""
|
||||
if not is_available():
|
||||
raise HTTPException(status_code=503, detail="数字分身数据库尚未初始化")
|
||||
|
||||
# 验证文件
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="仅支持图片文件")
|
||||
|
||||
file_bytes = await file.read()
|
||||
if len(file_bytes) > 5 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="头像文件不能超过5MB")
|
||||
|
||||
# 确保文件扩展名正确
|
||||
filename = file.filename or "avatar.jpg"
|
||||
ext = filename.split(".")[-1].lower() if "." in filename else "jpg"
|
||||
if ext not in ("jpg", "jpeg", "png", "gif", "webp"):
|
||||
filename = f"avatar.{ext}"
|
||||
|
||||
try:
|
||||
photo_url = await _upload_to_filecenter(file_bytes, filename)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"头像上传失败: {str(e)}")
|
||||
|
||||
# 更新数据库
|
||||
db = get_session()
|
||||
try:
|
||||
data = avatar_service.get_avatar(db, avatar_id)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="数字分身不存在")
|
||||
|
||||
from sqlalchemy import text
|
||||
db.execute(
|
||||
text("UPDATE avatars SET photo_url = :url, updated_at = datetime('now') WHERE id = :id"),
|
||||
{"url": photo_url, "id": avatar_id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 刷新数据
|
||||
updated = avatar_service.get_avatar(db, avatar_id)
|
||||
return ApiResponse(data=updated, message="头像上传成功")
|
||||
finally:
|
||||
db.close()
|
||||
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
+249
@@ -0,0 +1,249 @@
|
||||
"""互动记录接口"""
|
||||
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, platform_record_id = False, "未知类型", ""
|
||||
if record.interact_type == "comment" and record.content:
|
||||
success, err, platform_record_id = await news_service.post_comment_with_record_id(
|
||||
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)
|
||||
elif record.interact_type == "reply" and record.content:
|
||||
parent_comment = None
|
||||
if record.parent_comment_id:
|
||||
comments = await news_service.get_comments(db, user, record.article_id or "")
|
||||
parent_comment = next(
|
||||
(
|
||||
c for c in comments
|
||||
if str(c.get("id") or c.get("commentId") or "") == str(record.parent_comment_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not parent_comment:
|
||||
success, err, platform_record_id = False, "未找到被回复的原评论,无法重试回复", ""
|
||||
else:
|
||||
success, err, platform_record_id = await news_service.post_reply_with_record_id(
|
||||
db, user,
|
||||
record.article_id or "",
|
||||
record.parent_comment_id or "",
|
||||
record.content,
|
||||
parent_comment=parent_comment,
|
||||
article_title=record.article_title or "",
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=1 if success else 2,
|
||||
error_msg=None if success else err,
|
||||
platform_record_id=platform_record_id or record.platform_record_id,
|
||||
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 == "comment":
|
||||
comment_id = record.platform_record_id or ""
|
||||
if not comment_id:
|
||||
comment_id, comment_count = await news_service.find_comment_id(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
content=record.content or "",
|
||||
)
|
||||
if comment_id:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
platform_record_id=comment_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
elif comment_count == 0:
|
||||
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=400, message="缺少评论ID,且未能从平台评论列表反查到该评论")
|
||||
ok, err = await news_service.cancel_comment(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
comment_id=comment_id,
|
||||
)
|
||||
elif record.interact_type == "reply":
|
||||
reply_id = record.platform_record_id or ""
|
||||
if not reply_id:
|
||||
reply_id, reply_count = await news_service.find_reply_id(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
content=record.content or "",
|
||||
parent_comment_id=record.parent_comment_id or "",
|
||||
)
|
||||
if reply_id:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
platform_record_id=reply_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
elif reply_count == 0:
|
||||
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=400, message="缺少回复ID,且未能从平台回复列表反查到该回复")
|
||||
ok, err = await news_service.cancel_reply(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
reply_id=reply_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
+396
@@ -0,0 +1,396 @@
|
||||
"""虚拟用户管理接口"""
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
import mimetypes
|
||||
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()
|
||||
_UPLOADS_DIR = Path(__file__).resolve().parents[2] / "uploads" / "avatars"
|
||||
_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def _save_local_avatar(file_bytes: bytes, filename: str, content_type: str | None) -> str:
|
||||
ext = Path(filename or "").suffix.lower()
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
||||
guessed_ext = mimetypes.guess_extension(content_type or "") or ".jpg"
|
||||
ext = ".jpg" if guessed_ext == ".jpe" else guessed_ext
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
||||
ext = ".jpg"
|
||||
|
||||
target = _UPLOADS_DIR / f"{uuid.uuid4().hex}{ext}"
|
||||
target.write_bytes(file_bytes)
|
||||
return f"/api/uploads/avatars/{target.name}"
|
||||
|
||||
|
||||
@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
|
||||
ur = await s.execute(select(_VU).where(_VU.id == uid))
|
||||
user = ur.scalar_one_or_none()
|
||||
if not user:
|
||||
return False
|
||||
platform_uid = sess.get("platform_uid", "")
|
||||
# 登录成功时 session 里已存有用户信息
|
||||
vals = {}
|
||||
if platform_uid: vals["platform_uid"] = platform_uid
|
||||
# session 里的字段(登录时写入)
|
||||
sync_nickname = sess.get("nickname", "")
|
||||
sync_real_name = sess.get("real_name", "")
|
||||
resolved_nickname = news_service._resolve_synced_nickname(user, sync_nickname, sync_real_name)
|
||||
if resolved_nickname: vals["nickname"] = resolved_nickname
|
||||
if sync_real_name: vals["real_name"] = sync_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 超过 avatar_url 字段长度
|
||||
avatar_url = await _save_local_avatar(file_bytes, file.filename or "", file.content_type)
|
||||
|
||||
# 已登录用户必须同时写入会会当前资料和“TA 的主页”。
|
||||
# 任一接口失败都不得返回“头像更新成功”。
|
||||
if sync_to_platform and user.status == 2 and avatar_url:
|
||||
ok, err = await news_service.update_user_profile(db, user, avatar=avatar_url)
|
||||
if not ok:
|
||||
return ApiResponse(code=502, message=f"头像已上传,但同步到会会失败: {err}")
|
||||
else:
|
||||
await db.execute(update(_VU).where(_VU.id == user_id).values(avatar_url=avatar_url))
|
||||
await db.commit()
|
||||
|
||||
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
+42
-66
@@ -1,76 +1,52 @@
|
||||
"""
|
||||
系统配置模块
|
||||
"""
|
||||
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"
|
||||
)
|
||||
|
||||
# 数字分身 SQLite 数据库路径(直连数字分身应用的 SQLite)
|
||||
AVATAR_DB_PATH: str = os.getenv("AVATAR_DB_PATH", "")
|
||||
|
||||
# 数字分身后端服务地址(用于拼接头像等文件 URL)
|
||||
AVATAR_BACKEND_URL: str = os.getenv("AVATAR_BACKEND_URL", "")
|
||||
|
||||
# 日志目录
|
||||
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,
|
||||
PendingReplyTask, 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
+74
-59
@@ -1,95 +1,110 @@
|
||||
"""
|
||||
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
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import re as _re
|
||||
from pathlib import Path
|
||||
|
||||
# 修复 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.include_router(router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""根路径"""
|
||||
return {
|
||||
"name": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
"status": "running"
|
||||
}
|
||||
uploads_dir = Path(__file__).resolve().parent / "uploads"
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/api/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
||||
|
||||
|
||||
@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
+158
-24
@@ -1,25 +1,159 @@
|
||||
"""
|
||||
数据库模型初始化
|
||||
"""
|
||||
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 PendingReplyTask(Base):
|
||||
__tablename__ = "pending_reply_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
actor_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
next_actor_user_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
|
||||
news_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
news_title: Mapped[str | None] = mapped_column(String(256))
|
||||
article_org_id: Mapped[str | None] = mapped_column(String(64))
|
||||
parent_comment_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
parent_comment: Mapped[dict | None] = mapped_column(JSON)
|
||||
reply_to: Mapped[dict | None] = mapped_column(JSON)
|
||||
root_content: Mapped[str | None] = mapped_column(Text)
|
||||
context: Mapped[str | None] = mapped_column(Text)
|
||||
next_probability: Mapped[float] = mapped_column(Float, default=0.3)
|
||||
next_delay_min_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
||||
next_delay_max_seconds: Mapped[int] = mapped_column(Integer, default=7200)
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=0, index=True) # 0待发送 1发送中 2已发送 3失败
|
||||
attempts: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
scheduled_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
last_error: Mapped[str | None] = mapped_column(String(512))
|
||||
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 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
+265
-282
@@ -1,301 +1,284 @@
|
||||
"""
|
||||
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 generate_thread_reply(
|
||||
self, db: AsyncSession, article_title: str, root_comment: str,
|
||||
reply_context: str, personality_prompt: str,
|
||||
word_min: int = 15, word_max: int = 60
|
||||
) -> tuple[str, int]:
|
||||
"""结合文章、原评论和上下文生成评论回复链中的下一句。"""
|
||||
system_prompt = f"""你是一名真实的社区用户。{personality_prompt}
|
||||
|
||||
原评论:
|
||||
{original_comment}
|
||||
重要规则:
|
||||
- 回复必须积极正面、文明友善,不含任何敏感违规内容
|
||||
- 要自然接住对方的话,不要机械复述
|
||||
- 不要透露自己是AI或虚拟用户"""
|
||||
prompt = f"""文章标题:{article_title}
|
||||
原评论:{root_comment}
|
||||
当前对话上下文:{reply_context}
|
||||
|
||||
请写一条 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,
|
||||
请结合文章、原评论和当前对话,写一条自然的后续回复。
|
||||
要求:
|
||||
1. 字数 {word_min}~{word_max} 字
|
||||
2. 语气像真实用户交流,可以认同、补充或追问
|
||||
3. 必须围绕文章和评论内容,不要跑题
|
||||
4. 只输出回复正文,不要加任何前缀或解释
|
||||
|
||||
回复:"""
|
||||
return await self._call_api(db, prompt, system_prompt, max_tokens=180)
|
||||
|
||||
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": "模型不存在"}
|
||||
|
||||
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}"
|
||||
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""数字分身管理服务层 — 同步连接数字分身应用的 SQLite 数据库"""
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
_engine = None
|
||||
_SessionLocal: Optional[sessionmaker] = None
|
||||
|
||||
|
||||
def _get_engine_and_session():
|
||||
global _engine, _SessionLocal
|
||||
if _engine is None:
|
||||
db_path = settings.AVATAR_DB_PATH
|
||||
if not db_path:
|
||||
# 默认路径:从 backend/app/core/ 向上三级
|
||||
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
db_path = os.path.join(base, "digital-avatar-app", "backend", "avatar.db")
|
||||
if not os.path.isabs(db_path):
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not os.path.exists(db_path):
|
||||
# 数据库不存在时返回 None,由调用方处理
|
||||
return None, None
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{db_path}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
_SessionLocal = sessionmaker(bind=_engine, autoflush=False, expire_on_commit=False)
|
||||
return _engine, _SessionLocal()
|
||||
|
||||
|
||||
def get_session() -> Optional[Session]:
|
||||
_, session = _get_engine_and_session()
|
||||
return session
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""检查数字分身数据库是否可用"""
|
||||
engine, _ = _get_engine_and_session()
|
||||
return engine is not None
|
||||
|
||||
|
||||
def _resolve_photo_url(photo_url: str) -> str:
|
||||
"""将相对路径的头像 URL 补全为绝对路径"""
|
||||
if not photo_url:
|
||||
return ""
|
||||
if photo_url.startswith(("http://", "https://")):
|
||||
return photo_url
|
||||
base = settings.AVATAR_BACKEND_URL
|
||||
if base:
|
||||
base = base.rstrip("/")
|
||||
return f"{base}{photo_url}"
|
||||
return photo_url
|
||||
|
||||
|
||||
def _get_global_token_balance(db: Session) -> int:
|
||||
"""获取全局 token_account 余额(单行表)"""
|
||||
try:
|
||||
result = db.execute(text("SELECT balance FROM token_account LIMIT 1")).fetchone()
|
||||
return result.balance if result and result.balance else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
class AvatarService:
|
||||
|
||||
@staticmethod
|
||||
def list_avatars(
|
||||
db: Session,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> Tuple[int, list]:
|
||||
"""分页查询所有数字分身(含归属用户信息)"""
|
||||
where_clauses = []
|
||||
params = {}
|
||||
|
||||
if keyword:
|
||||
where_clauses.append(
|
||||
"(a.name LIKE :kw OR a.display_name LIKE :kw)"
|
||||
)
|
||||
params["kw"] = f"%{keyword}%"
|
||||
|
||||
if status:
|
||||
where_clauses.append("a.status = :status")
|
||||
params["status"] = status
|
||||
|
||||
where_sql = ""
|
||||
if where_clauses:
|
||||
where_sql = "WHERE " + " AND ".join(where_clauses)
|
||||
|
||||
# 计数
|
||||
count_sql = f"SELECT COUNT(*) FROM avatars a {where_sql}"
|
||||
total = db.execute(text(count_sql), params).scalar() or 0
|
||||
|
||||
# 分页查询
|
||||
offset = (page - 1) * page_size
|
||||
params["limit"] = page_size
|
||||
params["offset"] = offset
|
||||
|
||||
query = text(f"""
|
||||
SELECT
|
||||
a.id, a.name, a.display_name, a.description,
|
||||
a.photo_url, a.emoji, a.status, a.token_balance,
|
||||
a.config, a.created_at, a.updated_at,
|
||||
u.nickname AS owner_nickname,
|
||||
u.phone AS owner_phone
|
||||
FROM avatars a
|
||||
LEFT JOIN users u ON a.owner_id = u.huihui_user_id
|
||||
{where_sql}
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
rows = db.execute(query, params).fetchall()
|
||||
|
||||
items = []
|
||||
global_token = _get_global_token_balance(db)
|
||||
for row in rows:
|
||||
config = {}
|
||||
if row.config:
|
||||
if isinstance(row.config, str):
|
||||
import json
|
||||
try:
|
||||
config = json.loads(row.config)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
config = {}
|
||||
elif isinstance(row.config, dict):
|
||||
config = row.config
|
||||
items.append({
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"display_name": row.display_name,
|
||||
"description": row.description or "",
|
||||
"photo_url": _resolve_photo_url(row.photo_url),
|
||||
"emoji": row.emoji or "🤖",
|
||||
"status": row.status or "active",
|
||||
"token_balance": global_token or (row.token_balance or 0),
|
||||
"config": config,
|
||||
"owner_nickname": row.owner_nickname or "",
|
||||
"owner_phone": row.owner_phone or "",
|
||||
"created_at": str(row.created_at) if row.created_at else "",
|
||||
"updated_at": str(row.updated_at) if row.updated_at else "",
|
||||
})
|
||||
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def get_avatar(db: Session, avatar_id: str) -> Optional[dict]:
|
||||
"""获取单个数字分身详情"""
|
||||
query = text("""
|
||||
SELECT
|
||||
a.id, a.name, a.display_name, a.description,
|
||||
a.photo_url, a.emoji, a.status, a.token_balance,
|
||||
a.config, a.created_at, a.updated_at,
|
||||
u.nickname AS owner_nickname,
|
||||
u.phone AS owner_phone
|
||||
FROM avatars a
|
||||
LEFT JOIN users u ON a.owner_id = u.huihui_user_id
|
||||
WHERE a.id = :avatar_id
|
||||
""")
|
||||
row = db.execute(query, {"avatar_id": avatar_id}).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
config = {}
|
||||
if row.config:
|
||||
if isinstance(row.config, str):
|
||||
import json
|
||||
try:
|
||||
config = json.loads(row.config)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
config = {}
|
||||
elif isinstance(row.config, dict):
|
||||
config = row.config
|
||||
|
||||
global_token = _get_global_token_balance(db)
|
||||
return {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"display_name": row.display_name,
|
||||
"description": row.description or "",
|
||||
"photo_url": _resolve_photo_url(row.photo_url),
|
||||
"emoji": row.emoji or "🤖",
|
||||
"status": row.status or "active",
|
||||
"token_balance": global_token or (row.token_balance or 0),
|
||||
"config": config,
|
||||
"owner_nickname": row.owner_nickname or "",
|
||||
"owner_phone": row.owner_phone or "",
|
||||
"created_at": str(row.created_at) if row.created_at else "",
|
||||
"updated_at": str(row.updated_at) if row.updated_at else "",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_status(db: Session, avatar_id: str, status: str) -> Optional[dict]:
|
||||
"""更新数字分身状态(开关机)"""
|
||||
query = text("""
|
||||
UPDATE avatars SET status = :status, updated_at = datetime('now')
|
||||
WHERE id = :avatar_id
|
||||
""")
|
||||
result = db.execute(query, {"status": status, "avatar_id": avatar_id})
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
return None
|
||||
return AvatarService.get_avatar(db, avatar_id)
|
||||
|
||||
|
||||
avatar_service = AvatarService()
|
||||
@@ -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
+1530
File diff suppressed because it is too large
Load Diff
Executable
+922
@@ -0,0 +1,922 @@
|
||||
"""调度服务 - 定时自动互动、会话校验"""
|
||||
import random
|
||||
import asyncio
|
||||
from datetime import datetime, date, timedelta
|
||||
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, PendingReplyTask, 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:
|
||||
try:
|
||||
max_concurrent = int(await self._get_config(session, "max_concurrent_users", "5"))
|
||||
except (TypeError, ValueError):
|
||||
max_concurrent = 5
|
||||
max_concurrent = max(1, max_concurrent)
|
||||
|
||||
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": "没有已登录的用户",
|
||||
"requested_concurrency": max_concurrent,
|
||||
"attempted_count": 0,
|
||||
"success_count": 0,
|
||||
"skipped_count": 0,
|
||||
"failed_count": 0,
|
||||
"triggered": 0,
|
||||
"users": [],
|
||||
"results": [],
|
||||
}
|
||||
import random
|
||||
selected = random.sample(users, min(max_concurrent, len(users)))
|
||||
import asyncio
|
||||
tasks = [self._execute_user_interaction(u.id) for u in selected]
|
||||
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
results = []
|
||||
success_count = skipped_count = failed_count = 0
|
||||
for user, outcome in zip(selected, raw_results):
|
||||
if isinstance(outcome, Exception):
|
||||
item = {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "failed",
|
||||
"reason": str(outcome),
|
||||
"interactions": [],
|
||||
}
|
||||
failed_count += 1
|
||||
else:
|
||||
item = outcome or {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_result",
|
||||
"interactions": [],
|
||||
}
|
||||
status = item.get("status")
|
||||
if status == "success":
|
||||
success_count += 1
|
||||
elif status == "failed":
|
||||
failed_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
results.append(item)
|
||||
|
||||
return {
|
||||
"requested_concurrency": max_concurrent,
|
||||
"attempted_count": len(selected),
|
||||
"success_count": success_count,
|
||||
"skipped_count": skipped_count,
|
||||
"failed_count": failed_count,
|
||||
"triggered": len(selected),
|
||||
"users": [u.account for u in selected],
|
||||
"results": results,
|
||||
}
|
||||
|
||||
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._process_pending_reply_tasks, IntervalTrigger(seconds=30),
|
||||
id="process_pending_reply_tasks", 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 {
|
||||
"user_id": user_id,
|
||||
"account": getattr(user, "account", ""),
|
||||
"status": "skipped",
|
||||
"reason": "user_not_logged_in",
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
# 检查今日评论限额
|
||||
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 {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_articles",
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
# ── 文章去重 + 热度加权选取 ─────────────────────────────────
|
||||
# 查询今日已互动过的文章(所有类型),避免重复互动同一篇
|
||||
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 {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "missing_news_id",
|
||||
"interactions": [],
|
||||
"article_title": news_title,
|
||||
}
|
||||
|
||||
# 读取互动概率
|
||||
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 = []
|
||||
action_failures = []
|
||||
|
||||
# ① 先记录阅读(每次必做,模拟真实用户打开文章)
|
||||
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)
|
||||
else:
|
||||
action_failures.append({"type": "like", "error": err})
|
||||
|
||||
# ③ 收藏(每篇文章每用户每天只收藏一次)
|
||||
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")
|
||||
else:
|
||||
action_failures.append({"type": "collect", "error": err})
|
||||
|
||||
# ④ 转发(每篇文章每用户每天只转发一次)
|
||||
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)
|
||||
else:
|
||||
action_failures.append({"type": "forward", "error": err})
|
||||
|
||||
# ⑤ 评论/回复逻辑:评论和回复互相独立,未评论过文章也可以回复他人评论
|
||||
if can_comment and personality:
|
||||
style_prompt = personality.comment_style_prompt or ""
|
||||
safe_word_max = min(personality.word_count_max, 80)
|
||||
|
||||
if random.random() < reply_prob:
|
||||
reply_actions, reply_failures = await self._run_reply_interaction_chain(
|
||||
db=db,
|
||||
starter=user,
|
||||
starter_personality=personality,
|
||||
news_service=news_service,
|
||||
ai_service=ai_service,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
style_prompt=style_prompt,
|
||||
safe_word_max=safe_word_max,
|
||||
)
|
||||
interactions_done.extend(reply_actions)
|
||||
action_failures.extend(reply_failures)
|
||||
|
||||
# 每篇文章每个用户每天只发一条顶层评论;回复不再要求先评论
|
||||
if not already_commented_this and 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, comment_record_id = await news_service.post_comment_with_record_id(
|
||||
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,
|
||||
platform_record_id=comment_record_id,
|
||||
)
|
||||
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()
|
||||
)
|
||||
)
|
||||
else:
|
||||
action_failures.append({"type": "comment", "error": err})
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"👤 {user.account} 互动完成: {interactions_done} [新闻: {news_title[:20]}]")
|
||||
if interactions_done:
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "success",
|
||||
"reason": "",
|
||||
"interactions": interactions_done,
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
if action_failures:
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "failed",
|
||||
"reason": "; ".join(
|
||||
f"{item['type']}:{item['error'] or 'unknown'}" for item in action_failures
|
||||
),
|
||||
"interactions": [],
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_actions_triggered",
|
||||
"interactions": [],
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"用户 {user_id} 互动异常: {e}")
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"account": "",
|
||||
"status": "failed",
|
||||
"reason": str(e),
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
async def _run_reply_interaction_chain(
|
||||
self,
|
||||
db,
|
||||
starter: VirtualUser,
|
||||
starter_personality,
|
||||
news_service,
|
||||
ai_service,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
style_prompt: str,
|
||||
safe_word_max: int,
|
||||
) -> tuple[list[str], list[dict]]:
|
||||
"""主动回复评论,并按概率安排后续延迟回应。"""
|
||||
from app.core.redis_client import get_session
|
||||
|
||||
actions: list[str] = []
|
||||
failures: list[dict] = []
|
||||
starter_sess = await get_session(starter.id)
|
||||
starter_uid = str(starter_sess.get("platform_uid") or "") if starter_sess else ""
|
||||
comments = await news_service.get_comments(db, starter, news_id)
|
||||
if not comments:
|
||||
return actions, failures
|
||||
|
||||
candidates = [
|
||||
c for c in comments
|
||||
if str(c.get("id") or c.get("commentId") or "")
|
||||
and (c.get("content") or "").strip()
|
||||
and str(c.get("userId") or c.get("createUser") or "") != starter_uid
|
||||
]
|
||||
if not candidates:
|
||||
return actions, failures
|
||||
|
||||
parent_comment = random.choice(candidates)
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
root_content = (parent_comment.get("content") or "").strip()
|
||||
context = f"准备回复这条评论:{root_content}"
|
||||
reply_text, reply_tokens = await ai_service.generate_thread_reply(
|
||||
db, news_title, root_content, context,
|
||||
style_prompt,
|
||||
starter_personality.word_count_min,
|
||||
safe_word_max,
|
||||
)
|
||||
if not reply_text:
|
||||
return actions, failures
|
||||
|
||||
ok, err, reply_id = await news_service.post_reply_with_record_id(
|
||||
db, starter, news_id, parent_comment_id, reply_text,
|
||||
parent_comment=parent_comment,
|
||||
article_title=news_title,
|
||||
org_id=article_org_id,
|
||||
)
|
||||
await self._save_record(
|
||||
db, starter, news_id, news_title, "reply",
|
||||
reply_text, reply_tokens, ok, err,
|
||||
parent_comment_id=parent_comment_id,
|
||||
platform_record_id=reply_id,
|
||||
)
|
||||
if not ok:
|
||||
failures.append({"type": "reply", "error": err})
|
||||
return actions, failures
|
||||
|
||||
actions.append("reply")
|
||||
await self._incr_total(db, starter.id)
|
||||
logger.info(f"💬 {starter.account} 回复了文章评论")
|
||||
|
||||
starter_reply = {
|
||||
"id": reply_id,
|
||||
"content": reply_text,
|
||||
"createUser": starter_uid,
|
||||
"fromUserName": starter.real_name or starter.nickname or starter.account,
|
||||
}
|
||||
|
||||
chain_probability = await self._get_float_config(db, "reply_chain_probability", 0.3)
|
||||
delay_min = await self._get_int_config(db, "reply_chain_delay_min_seconds", 30)
|
||||
delay_max = await self._get_int_config(db, "reply_chain_delay_max_seconds", 7200)
|
||||
if delay_max < delay_min:
|
||||
delay_max = delay_min
|
||||
|
||||
# 评论作者如果也是当前系统里的已登录虚拟用户,按概率安排稍后回复这条回复。
|
||||
parent_author_uid = str(parent_comment.get("createUser") or parent_comment.get("userId") or "")
|
||||
parent_author = await self._get_logged_in_user_by_platform_uid(db, parent_author_uid)
|
||||
if (
|
||||
parent_author
|
||||
and parent_author.id != starter.id
|
||||
and random.random() < chain_probability
|
||||
):
|
||||
delay_seconds = random.randint(delay_min, delay_max)
|
||||
await self._enqueue_pending_reply_task(
|
||||
db=db,
|
||||
delay_seconds=delay_seconds,
|
||||
actor_id=parent_author.id,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=starter_reply,
|
||||
root_content=root_content,
|
||||
context=f"对方刚回复了你的评论:{reply_text}",
|
||||
next_actor_id=starter.id,
|
||||
next_probability=chain_probability,
|
||||
next_delay_min=delay_min,
|
||||
next_delay_max=delay_max,
|
||||
)
|
||||
logger.info(
|
||||
f"⏳ {parent_author.account} 已进入待发送回复队列,延迟 {delay_seconds}s 后发送"
|
||||
)
|
||||
|
||||
return actions, failures
|
||||
|
||||
async def _enqueue_pending_reply_task(
|
||||
self,
|
||||
db,
|
||||
delay_seconds: int,
|
||||
actor_id: int,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
parent_comment: dict,
|
||||
reply_to: dict,
|
||||
root_content: str,
|
||||
context: str,
|
||||
next_actor_id: int | None = None,
|
||||
next_probability: float = 0.3,
|
||||
next_delay_min: int = 30,
|
||||
next_delay_max: int = 7200,
|
||||
):
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
db.add(PendingReplyTask(
|
||||
actor_user_id=actor_id,
|
||||
next_actor_user_id=next_actor_id,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
parent_comment_id=parent_comment_id,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=reply_to,
|
||||
root_content=root_content,
|
||||
context=context,
|
||||
next_probability=next_probability,
|
||||
next_delay_min_seconds=next_delay_min,
|
||||
next_delay_max_seconds=next_delay_max,
|
||||
status=0,
|
||||
attempts=0,
|
||||
scheduled_at=datetime.now() + timedelta(seconds=max(0, delay_seconds)),
|
||||
))
|
||||
|
||||
async def _process_pending_reply_tasks(self):
|
||||
from app.services.news_service import news_service
|
||||
from app.services.ai_service import ai_service
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
now = datetime.now()
|
||||
await db.execute(
|
||||
update(PendingReplyTask)
|
||||
.where(
|
||||
PendingReplyTask.status == 1,
|
||||
PendingReplyTask.locked_at < now - timedelta(minutes=10),
|
||||
)
|
||||
.values(status=0, last_error="发送中超时,重新入队")
|
||||
)
|
||||
result = await db.execute(
|
||||
select(PendingReplyTask)
|
||||
.where(
|
||||
PendingReplyTask.status == 0,
|
||||
PendingReplyTask.scheduled_at <= now,
|
||||
)
|
||||
.order_by(PendingReplyTask.scheduled_at.asc())
|
||||
.limit(10)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
for task in tasks:
|
||||
await self._process_pending_reply_task(db, task, news_service, ai_service)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"待发送回复队列处理异常: {e}")
|
||||
|
||||
async def _process_pending_reply_task(self, db, task: PendingReplyTask, news_service, ai_service):
|
||||
task.status = 1
|
||||
task.locked_at = datetime.now()
|
||||
task.attempts = (task.attempts or 0) + 1
|
||||
await db.flush()
|
||||
|
||||
actor = await self._get_user_by_id(db, task.actor_user_id)
|
||||
if not actor or actor.status != 2 or actor.is_enabled != 1:
|
||||
task.status = 3
|
||||
task.last_error = "用户未登录或已禁用"
|
||||
return
|
||||
|
||||
reply_result = await self._post_contextual_reply(
|
||||
db=db,
|
||||
actor=actor,
|
||||
news_service=news_service,
|
||||
ai_service=ai_service,
|
||||
news_id=task.news_id,
|
||||
news_title=task.news_title or "",
|
||||
article_org_id=task.article_org_id or "",
|
||||
parent_comment=task.parent_comment or {},
|
||||
reply_to=task.reply_to or {},
|
||||
root_content=task.root_content or "",
|
||||
context=task.context or "",
|
||||
)
|
||||
if not reply_result["ok"]:
|
||||
task.status = 3 if task.attempts >= 3 else 0
|
||||
task.last_error = reply_result["error"] or "生成或发送回复失败"
|
||||
if task.status == 0:
|
||||
task.scheduled_at = datetime.now() + timedelta(minutes=5)
|
||||
logger.warning(f"待发送回复失败 task_id={task.id} user={actor.account}: {task.last_error}")
|
||||
return
|
||||
|
||||
task.status = 2
|
||||
task.sent_at = datetime.now()
|
||||
task.last_error = None
|
||||
await self._incr_total(db, actor.id)
|
||||
logger.info(f"💬 {actor.account} 发送了待发送回复 task_id={task.id}")
|
||||
|
||||
if task.next_actor_user_id and random.random() < float(task.next_probability or 0.3):
|
||||
delay_min = int(task.next_delay_min_seconds or 30)
|
||||
delay_max = max(delay_min, int(task.next_delay_max_seconds or 7200))
|
||||
next_delay = random.randint(delay_min, delay_max)
|
||||
await self._enqueue_pending_reply_task(
|
||||
db=db,
|
||||
delay_seconds=next_delay,
|
||||
actor_id=task.next_actor_user_id,
|
||||
news_id=task.news_id,
|
||||
news_title=task.news_title or "",
|
||||
article_org_id=task.article_org_id or "",
|
||||
parent_comment=task.parent_comment or {},
|
||||
reply_to=reply_result["reply"],
|
||||
root_content=task.root_content or "",
|
||||
context=(
|
||||
f"原评论:{task.root_content or ''}\n"
|
||||
f"上一条回复:{(task.reply_to or {}).get('content') or ''}\n"
|
||||
f"对方回应:{reply_result['content']}"
|
||||
),
|
||||
next_actor_id=None,
|
||||
next_probability=float(task.next_probability or 0.3),
|
||||
next_delay_min=delay_min,
|
||||
next_delay_max=delay_max,
|
||||
)
|
||||
logger.info(f"⏳ 已入队继续回复,延迟 {next_delay}s 后发送")
|
||||
|
||||
async def _post_contextual_reply(
|
||||
self,
|
||||
db,
|
||||
actor: VirtualUser,
|
||||
news_service,
|
||||
ai_service,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
parent_comment: dict,
|
||||
reply_to: dict,
|
||||
root_content: str,
|
||||
context: str,
|
||||
) -> dict:
|
||||
personality = await self._get_user_personality(db, actor.id)
|
||||
style_prompt = personality.comment_style_prompt if personality else ""
|
||||
word_min = personality.word_count_min if personality else 15
|
||||
word_max = min(personality.word_count_max, 80) if personality else 60
|
||||
content, tokens = await ai_service.generate_thread_reply(
|
||||
db, news_title, root_content, context,
|
||||
style_prompt, word_min, word_max,
|
||||
)
|
||||
if not content:
|
||||
return {"ok": False, "error": "", "reply": {}, "content": ""}
|
||||
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
ok, err, reply_id = await news_service.post_reply_with_record_id(
|
||||
db, actor, news_id, parent_comment_id, content,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=reply_to,
|
||||
article_title=news_title,
|
||||
org_id=article_org_id,
|
||||
)
|
||||
await self._save_record(
|
||||
db, actor, news_id, news_title, "reply",
|
||||
content, tokens, ok, err,
|
||||
parent_comment_id=parent_comment_id,
|
||||
platform_record_id=reply_id,
|
||||
)
|
||||
from app.core.redis_client import get_session
|
||||
sess = await get_session(actor.id)
|
||||
actor_uid = str(sess.get("platform_uid") or "") if sess else ""
|
||||
return {
|
||||
"ok": ok,
|
||||
"error": "" if ok else err,
|
||||
"content": content,
|
||||
"reply": {
|
||||
"id": reply_id,
|
||||
"content": content,
|
||||
"createUser": actor_uid,
|
||||
"fromUserName": actor.real_name or actor.nickname or actor.account,
|
||||
},
|
||||
}
|
||||
|
||||
async def _get_logged_in_user_by_platform_uid(self, db, platform_uid: str) -> VirtualUser | None:
|
||||
if not platform_uid:
|
||||
return None
|
||||
result = await db.execute(
|
||||
select(VirtualUser).where(
|
||||
VirtualUser.platform_uid == platform_uid,
|
||||
VirtualUser.status == 2,
|
||||
VirtualUser.is_enabled == 1,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_user_by_id(self, db, user_id: int) -> VirtualUser | None:
|
||||
result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_user_personality(self, db, user_id: int):
|
||||
result = await db.execute(
|
||||
select(UserPersonality).where(UserPersonality.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_float_config(self, db, key: str, default: float) -> float:
|
||||
try:
|
||||
return float(await self._get_config_from_db(db, key, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
async def _get_int_config(self, db, key: str, default: int) -> int:
|
||||
try:
|
||||
return int(float(await self._get_config_from_db(db, key, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
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: "失败", 3: "已取消"}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.services.news_service import NewsPlatformService
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.text = str(payload)
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _Client:
|
||||
responses = []
|
||||
calls = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def patch(self, url, **kwargs):
|
||||
self.__class__.calls.append(("PATCH", url, kwargs))
|
||||
return self.__class__.responses.pop(0)
|
||||
|
||||
async def get(self, url, **kwargs):
|
||||
self.__class__.calls.append(("GET", url, kwargs))
|
||||
return self.__class__.responses.pop(0)
|
||||
|
||||
|
||||
class HuihuiProfileSyncTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.service = NewsPlatformService()
|
||||
self.service._auth_url = AsyncMock(return_value="https://99hui.com/api/usercenter")
|
||||
self.service._biz_url = AsyncMock(return_value="https://99hui.com/api/huihuibusiness")
|
||||
self.service._client = AsyncMock(return_value={
|
||||
"appId": "app", "accessId": "access", "accessSecret": "secret",
|
||||
"clientCode": "", "orgId": "",
|
||||
})
|
||||
self.db = SimpleNamespace(execute=AsyncMock(), commit=AsyncMock())
|
||||
self.user = SimpleNamespace(
|
||||
id=51, account="13721560046", platform_uid="platform-51",
|
||||
nickname="黎佳怡", real_name="黎佳怡", sex=0, avatar_url="https://img/avatar.jpg",
|
||||
)
|
||||
_Client.calls = []
|
||||
|
||||
async def test_updates_all_profiles_and_verifies_app_home(self):
|
||||
_Client.responses = [
|
||||
_Response({"code": 0, "data": True}),
|
||||
_Response({"code": 0, "data": True}),
|
||||
_Response({"code": 0, "data": {
|
||||
"id": "extend-51", "userId": "platform-51", "name": "", "avatar": "",
|
||||
}}),
|
||||
_Response({"code": 0, "data": None}),
|
||||
_Response({"code": 0, "data": {
|
||||
"id": "extend-51", "userId": "platform-51", "name": "黎佳怡",
|
||||
"avatar": "https://img/avatar.jpg",
|
||||
}}),
|
||||
]
|
||||
session = {"token": "token", "platform_uid": "platform-51", "org_id": "org-1"}
|
||||
with patch("app.services.news_service.get_session", AsyncMock(return_value=session)), \
|
||||
patch("app.services.news_service.httpx.AsyncClient", _Client):
|
||||
ok, err = await self.service.update_user_profile(
|
||||
self.db, self.user,
|
||||
nick_name="黎佳怡", real_name="黎佳怡",
|
||||
avatar="https://img/avatar.jpg",
|
||||
)
|
||||
|
||||
self.assertTrue(ok, err)
|
||||
self.assertEqual(_Client.calls[0][1], "https://99hui.com/api/usercenter/v2/users/current")
|
||||
self.assertEqual(_Client.calls[1][1], "https://99hui.com/api/usercenter/users/page/platform-51")
|
||||
public_params = _Client.calls[1][2]["params"]
|
||||
self.assertEqual(public_params["nickName"], "黎佳怡")
|
||||
self.assertEqual(public_params["icon"], "https://img/avatar.jpg")
|
||||
self.assertEqual(_Client.calls[2][0], "GET")
|
||||
self.assertEqual(
|
||||
_Client.calls[2][1],
|
||||
"https://99hui.com/api/huihuiuserextend/user/home/platform-51",
|
||||
)
|
||||
self.assertEqual(_Client.calls[3][0], "PATCH")
|
||||
self.assertEqual(
|
||||
_Client.calls[3][1],
|
||||
"https://99hui.com/api/huihuiuserextend/user",
|
||||
)
|
||||
self.assertEqual(_Client.calls[3][2]["json"]["id"], "extend-51")
|
||||
self.assertEqual(_Client.calls[3][2]["json"]["name"], "黎佳怡")
|
||||
self.assertEqual(_Client.calls[3][2]["json"]["avatar"], "https://img/avatar.jpg")
|
||||
self.assertEqual(_Client.calls[4][0], "GET")
|
||||
self.db.commit.assert_awaited_once()
|
||||
|
||||
async def test_public_page_failure_is_not_reported_as_success(self):
|
||||
_Client.responses = [
|
||||
_Response({"code": 0, "data": True}),
|
||||
_Response({"code": 500, "message": "page update failed"}),
|
||||
]
|
||||
session = {"token": "token", "platform_uid": "platform-51"}
|
||||
with patch("app.services.news_service.get_session", AsyncMock(return_value=session)), \
|
||||
patch("app.services.news_service.httpx.AsyncClient", _Client):
|
||||
ok, err = await self.service.update_user_profile(
|
||||
self.db, self.user,
|
||||
nick_name="黎佳怡", avatar="https://img/avatar.jpg",
|
||||
)
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("TA的主页同步失败", err)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_app_home_mismatch_is_not_reported_as_success(self):
|
||||
_Client.responses = [
|
||||
_Response({"code": 0, "data": True}),
|
||||
_Response({"code": 0, "data": True}),
|
||||
_Response({"code": 0, "data": {
|
||||
"id": "extend-51", "userId": "platform-51", "name": "", "avatar": "",
|
||||
}}),
|
||||
_Response({"code": 0, "data": None}),
|
||||
_Response({"code": 0, "data": {
|
||||
"id": "extend-51", "userId": "platform-51", "name": "", "avatar": "",
|
||||
}}),
|
||||
]
|
||||
session = {"token": "token", "platform_uid": "platform-51", "org_id": "org-1"}
|
||||
with patch("app.services.news_service.get_session", AsyncMock(return_value=session)), \
|
||||
patch("app.services.news_service.httpx.AsyncClient", _Client):
|
||||
ok, err = await self.service.update_user_profile(
|
||||
self.db, self.user,
|
||||
nick_name="黎佳怡", avatar="https://img/avatar.jpg",
|
||||
)
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("App用户主页同步失败", err)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.git/
|
||||
.env
|
||||
*.log
|
||||
backend/
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,22 @@
|
||||
# 构建阶段:安装依赖并打包 H5
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
# 跳过 vue-tsc 类型检查直接打包(与本机已知 vue-tsc + Node 版本兼容问题无关,保证可构建)
|
||||
RUN npx vite build
|
||||
|
||||
# 运行阶段:nginx 托管静态资源并反向代理 /api 到后端
|
||||
# 锁定 1.28-alpine:测试服务器 Docker 的 seccomp 拦截 pwrite 系统调用,
|
||||
# 新版 nginx(>=1.31) 用 pwrite 写 pid 文件会被拦导致致命退出;1.28 用 write() 可正常启动。
|
||||
FROM nginx:1.28-alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# 覆盖 nginx 默认主配置(含唯一可写的 pid /tmp/nginx.pid,规避受限容器内 /run 不可写导致反复重启)
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.env
|
||||
logs/
|
||||
*.log
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 后端依赖(fastapi/uvicorn/sqlalchemy/pypdf/python-docx/openpyxl 等)均为纯 Python wheel,
|
||||
# 无需 gcc 等编译链,故跳过 apt 安装以加快构建并减小镜像体积。
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 120 --retries 10 -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# 后端使用 SQLite(avatar.db 落在 /app 内),平铺结构以 `uvicorn main:app` 启动
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
@@ -0,0 +1,52 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base, Session
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DB_FILE = os.path.join(BASE_DIR, "avatar.db")
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{DB_FILE}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
import models
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 轻量迁移:为已存在的表补充新列(SQLite 不支持自动 ALTER,逐列尝试)
|
||||
_try_add_columns(
|
||||
("qa_pairs", "enabled", "BOOLEAN DEFAULT 1"),
|
||||
("knowledge_docs", "vectorized", "BOOLEAN DEFAULT 0"),
|
||||
("knowledge_docs", "embedding_model", "VARCHAR DEFAULT ''"),
|
||||
("knowledge_docs", "chunk_count", "INTEGER DEFAULT 0"),
|
||||
("knowledge_docs", "vectorized_at", "TIMESTAMP"),
|
||||
("avatars", "owner_id", "VARCHAR DEFAULT ''"),
|
||||
("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"),
|
||||
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"),
|
||||
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"),
|
||||
)
|
||||
|
||||
|
||||
def _try_add_columns(*cols):
|
||||
with engine.connect() as conn:
|
||||
for table, col, ddl in cols:
|
||||
try:
|
||||
conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# 列已存在(或全新库由 create_all 建好)则忽略
|
||||
pass
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
向量化服务:对文档/查询文本生成向量。
|
||||
|
||||
优先级:
|
||||
1. 若配置了环境变量 EMBEDDING_API_URL,则调用第三方「OpenAI 兼容」的 /embeddings 接口
|
||||
(需配置 EMBEDDING_API_KEY、EMBEDDING_MODEL,默认 text-embedding-3-small)。
|
||||
2. 否则使用本地「哈希 TF 嵌入」兜底,使向量检索在无外部依赖时也能端到端跑通,
|
||||
且相似文本(共享词汇)会得到更高余弦相似度,便于演示召回效果。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import math
|
||||
import json
|
||||
import hashlib
|
||||
import urllib.request
|
||||
|
||||
EMBED_DIM = 256
|
||||
MODEL = os.getenv("EMBEDDING_MODEL", "mock-hash-embed-v1")
|
||||
|
||||
|
||||
def _tokenize(text):
|
||||
text = (text or "").lower()
|
||||
# 英文/数字按词,CJK 逐字(中文无空格,需拆到字级才能命中子词)
|
||||
tokens = re.findall(r"[a-z0-9]+", text)
|
||||
tokens += re.findall(r"[一-鿿]", text)
|
||||
return tokens
|
||||
|
||||
|
||||
def _hash_embedding(texts, dim=EMBED_DIM):
|
||||
vecs = []
|
||||
for text in texts:
|
||||
vec = [0.0] * dim
|
||||
tokens = _tokenize(text)
|
||||
if not tokens:
|
||||
tokens = list(text or "")
|
||||
for tok in tokens:
|
||||
h = int(hashlib.md5(tok.encode("utf-8")).hexdigest(), 16)
|
||||
vec[h % dim] += 1.0
|
||||
norm = math.sqrt(sum(v * v for v in vec))
|
||||
if norm > 0:
|
||||
vec = [v / norm for v in vec]
|
||||
vecs.append(vec)
|
||||
return vecs
|
||||
|
||||
|
||||
def embed(texts):
|
||||
"""返回 list[list[float]],与输入顺序一致。"""
|
||||
if not texts:
|
||||
return []
|
||||
api_url = os.getenv("EMBEDDING_API_URL")
|
||||
if api_url:
|
||||
api_key = os.getenv("EMBEDDING_API_KEY", "")
|
||||
model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
payload = json.dumps({"input": texts, "model": model}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
api_url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}" if api_key else "",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
items = data["data"]
|
||||
if items and "index" in items[0]:
|
||||
items = sorted(items, key=lambda x: x["index"])
|
||||
return [item["embedding"] for item in items]
|
||||
return _hash_embedding(texts)
|
||||
|
||||
|
||||
def cosine(a, b):
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(y * y for y in b))
|
||||
if na == 0 or nb == 0:
|
||||
return 0.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
def chunk_text(text, size=400, overlap=50):
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
if len(text) <= size:
|
||||
return [text]
|
||||
chunks = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = min(start + size, len(text))
|
||||
chunks.append(text[start:end])
|
||||
if end == len(text):
|
||||
break
|
||||
start = end - overlap
|
||||
return chunks
|
||||
|
||||
|
||||
def extract_text(path, ext):
|
||||
"""抽取文档纯文本;未知格式拒绝,已知格式解析失败时保留占位文本。"""
|
||||
if ext not in {".txt", ".md", ".docx", ".xlsx", ".pdf", ".doc"}:
|
||||
raise ValueError(f"unsupported file extension: {ext}")
|
||||
try:
|
||||
if ext in {".txt", ".md"}:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
if ext == ".docx":
|
||||
from docx import Document
|
||||
|
||||
doc = Document(path)
|
||||
return "\n".join(p.text for p in doc.paragraphs)
|
||||
if ext == ".xlsx":
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
|
||||
rows = []
|
||||
for ws in wb.worksheets:
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
cells = [str(c) for c in row if c is not None]
|
||||
if cells:
|
||||
rows.append(" ".join(cells))
|
||||
return "\n".join(rows)
|
||||
if ext == ".pdf":
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
from PyPDF2 import PdfReader
|
||||
reader = PdfReader(path)
|
||||
return "\n".join((p.extract_text() or "") for p in reader.pages)
|
||||
if ext == ".doc":
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read().decode("utf-8", errors="ignore")
|
||||
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]+", " ", raw)
|
||||
except Exception as e: # 解析失败时回退
|
||||
print("extract_text failed:", e)
|
||||
return f"文档:{os.path.basename(path)} 类型 {ext}"
|
||||
@@ -0,0 +1,152 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
import redis as redis_lib
|
||||
|
||||
from database import init_db, SessionLocal
|
||||
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import routers.avatars
|
||||
import routers.tokens
|
||||
import routers.authorizations
|
||||
import routers.organizations
|
||||
import routers.knowledge
|
||||
import routers.huihui_auth
|
||||
import routers.chat
|
||||
import routers.takeover
|
||||
from responses import ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="会会数字分身 API", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(routers.avatars.router, prefix="/api")
|
||||
app.include_router(routers.tokens.router, prefix="/api")
|
||||
app.include_router(routers.authorizations.router, prefix="/api")
|
||||
app.include_router(routers.organizations.router, prefix="/api")
|
||||
app.include_router(routers.knowledge.router, prefix="/api")
|
||||
app.include_router(routers.huihui_auth.router, prefix="/api")
|
||||
app.include_router(routers.chat.router, prefix="/api")
|
||||
app.include_router(routers.takeover.router, prefix="/api")
|
||||
|
||||
UPLOAD_DIR = routers.knowledge.UPLOAD_DIR
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
app.mount("/api/files", StaticFiles(directory=UPLOAD_DIR), name="knowledge-files")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return ok({"status": "ok"})
|
||||
|
||||
|
||||
def seed():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if db.query(TokenAccount).first() is None:
|
||||
db.add(TokenAccount(balance=1250))
|
||||
|
||||
if db.query(TokenPlan).count() == 0:
|
||||
plans = [
|
||||
TokenPlan(id="1", name="新手体验", amount=1000, price=9.9, desc="新手体验"),
|
||||
TokenPlan(id="2", name="热门套餐", amount=5000, price=39.9, badge="热门"),
|
||||
TokenPlan(id="3", name="超值套餐", amount=12000, price=89.9, badge="超值"),
|
||||
TokenPlan(id="4", name="企业推荐", amount=30000, price=199, badge="企业推荐", desc="适合高频使用"),
|
||||
]
|
||||
db.add_all(plans)
|
||||
|
||||
if db.query(Avatar).count() == 0:
|
||||
avatar = Avatar(
|
||||
name="我的数字分身",
|
||||
display_name="会会助手",
|
||||
description="我是您的AI数字分身,可以帮您管理日程、回复消息、处理任务。",
|
||||
emoji="🤖",
|
||||
status="active",
|
||||
token_balance=0,
|
||||
config={
|
||||
"replyStyle": "professional",
|
||||
"creativity": 50,
|
||||
"rigor": 50,
|
||||
"humor": 30,
|
||||
"responseLength": "medium",
|
||||
"systemPrompt": "",
|
||||
},
|
||||
)
|
||||
db.add(avatar)
|
||||
db.commit()
|
||||
db.refresh(avatar)
|
||||
|
||||
if db.query(Authorization).count() == 0:
|
||||
auths = [
|
||||
Authorization(avatar_id=avatar.id, target_type="application", target_name="微信小程序", permissions=["read", "reply"], status="active"),
|
||||
Authorization(avatar_id=avatar.id, target_type="user", target_name="张三", permissions=["read"], status="active"),
|
||||
Authorization(avatar_id=avatar.id, target_type="organization", target_name="产品团队", permissions=["read", "edit"], status="inactive"),
|
||||
]
|
||||
db.add_all(auths)
|
||||
|
||||
if db.query(Organization).count() == 0:
|
||||
orgs = [
|
||||
Organization(name="会会增长团队", description="负责会会产品的增长与运营", emoji="🚀", org_type="team", member_count=12),
|
||||
Organization(name="AI 实验室", description="探索前沿 AI 能力", emoji="💡", org_type="company", member_count=8),
|
||||
]
|
||||
db.add_all(orgs)
|
||||
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
seed()
|
||||
|
||||
# --- Takeover scheduler ---
|
||||
try:
|
||||
# Initialize Redis (optional)
|
||||
redis_client = None
|
||||
redis_url = os.getenv("REDIS_URL", "")
|
||||
if redis_url:
|
||||
try:
|
||||
redis_client = redis_lib.from_url(redis_url)
|
||||
redis_client.ping()
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis connection failed, delayed takeover will degrade to immediate: {e}")
|
||||
|
||||
# Initialize Box IM client
|
||||
from services.boxim_client import BoxIMClient
|
||||
boxim_config = {
|
||||
"HUIHUI_IM_BASE_URL": os.getenv("HUIHUI_IM_BASE_URL", "http://192.168.1.200:60040"),
|
||||
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
|
||||
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
|
||||
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
|
||||
}
|
||||
boxim_client = BoxIMClient(boxim_config)
|
||||
|
||||
# Initialize takeover service
|
||||
from services.takeover_service import TakeoverService
|
||||
takeover_service = TakeoverService(SessionLocal(), boxim_client, redis_client)
|
||||
|
||||
# Start periodic polling job
|
||||
scheduler = BackgroundScheduler()
|
||||
scheduler.add_job(
|
||||
takeover_service.poll_and_process_messages,
|
||||
trigger=IntervalTrigger(seconds=10),
|
||||
id="takeover_message_poll",
|
||||
)
|
||||
scheduler.start()
|
||||
logger.info("Takeover message polling scheduler started (interval=10s)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize takeover scheduler, app will continue without it: {e}")
|
||||
@@ -0,0 +1,226 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, JSON, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from database import Base
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
return dt.isoformat() if dt else None
|
||||
|
||||
|
||||
class Avatar(Base):
|
||||
__tablename__ = "avatars"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
owner_id = Column(String, default="", index=True) # 归属用户(会会 huihui_user_id);空=未归属/种子数据
|
||||
name = Column(String, nullable=False)
|
||||
display_name = Column(String, default="")
|
||||
description = Column(Text, default="")
|
||||
photo_url = Column(String, default="")
|
||||
emoji = Column(String, default="🤖")
|
||||
status = Column(String, default="active") # active | inactive | training
|
||||
token_balance = Column(Integer, default=0)
|
||||
config = Column(JSON, default=dict)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"ownerId": self.owner_id,
|
||||
"name": self.name,
|
||||
"displayName": self.display_name,
|
||||
"description": self.description,
|
||||
"photoUrl": self.photo_url,
|
||||
"emoji": self.emoji,
|
||||
"status": self.status,
|
||||
"tokenBalance": self.token_balance,
|
||||
"config": self.config or {},
|
||||
"createdAt": _iso(self.created_at),
|
||||
"updatedAt": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class Authorization(Base):
|
||||
__tablename__ = "authorizations"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
target_type = Column(String, default="user") # user | organization | application
|
||||
target_id = Column(String, default="")
|
||||
target_name = Column(String, default="")
|
||||
permissions = Column(JSON, default=list)
|
||||
status = Column(String, default="active") # active | inactive
|
||||
takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管
|
||||
takeover_mode = Column(String, default="immediate") # immediate | delayed
|
||||
takeover_delay_seconds = Column(Integer, default=30) # 延迟秒数
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"targetType": self.target_type,
|
||||
"targetId": self.target_id,
|
||||
"targetName": self.target_name,
|
||||
"permissions": self.permissions or [],
|
||||
"status": self.status,
|
||||
"takeoverEnabled": self.takeover_enabled,
|
||||
"takeoverMode": self.takeover_mode,
|
||||
"takeoverDelaySeconds": self.takeover_delay_seconds,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class Organization(Base):
|
||||
__tablename__ = "organizations"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(Text, default="")
|
||||
emoji = Column(String, default="🏢")
|
||||
org_type = Column(String, default="team") # team | company | community
|
||||
role = Column(String, default="admin") # admin | member | viewer
|
||||
member_count = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"emoji": self.emoji,
|
||||
"type": self.org_type,
|
||||
"role": self.role,
|
||||
"memberCount": self.member_count,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class KnowledgeDoc(Base):
|
||||
__tablename__ = "knowledge_docs"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
filename = Column(String, default="")
|
||||
file_type = Column(String, default="") # pdf | doc | docx | xlsx
|
||||
file_size = Column(Integer, default=0)
|
||||
file_url = Column(String, default="")
|
||||
status = Column(String, default="uploaded") # uploaded | parsing | ready
|
||||
vectorized = Column(Boolean, default=False) # 是否已向量化
|
||||
embedding_model = Column(String, default="") # 向量模型标识
|
||||
chunk_count = Column(Integer, default=0) # 切片数量
|
||||
vectorized_at = Column(DateTime) # 向量化时间
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"filename": self.filename,
|
||||
"fileType": self.file_type,
|
||||
"fileSize": self.file_size,
|
||||
"fileUrl": self.file_url,
|
||||
"status": self.status,
|
||||
"vectorized": bool(self.vectorized),
|
||||
"embeddingModel": self.embedding_model,
|
||||
"chunkCount": self.chunk_count,
|
||||
"vectorizedAt": _iso(self.vectorized_at),
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class QAPair(Base):
|
||||
__tablename__ = "qa_pairs"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
question = Column(Text, default="")
|
||||
answer = Column(Text, default="")
|
||||
enabled = Column(Boolean, default=True) # 是否启用(关闭后不参与作答)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"question": self.question,
|
||||
"answer": self.answer,
|
||||
"enabled": bool(self.enabled),
|
||||
"createdAt": _iso(self.created_at),
|
||||
"updatedAt": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class KnowledgeChunk(Base):
|
||||
__tablename__ = "knowledge_chunks"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
doc_id = Column(String, default="") # 关联 KnowledgeDoc.id
|
||||
avatar_id = Column(String, default="")
|
||||
content = Column(Text, default="") # 切片文本
|
||||
vector = Column(Text, default="") # JSON 编码的向量
|
||||
chunk_index = Column(Integer, default=0)
|
||||
embedding_model = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"docId": self.doc_id,
|
||||
"avatarId": self.avatar_id,
|
||||
"content": self.content,
|
||||
"chunkIndex": self.chunk_index,
|
||||
"embeddingModel": self.embedding_model,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class TokenAccount(Base):
|
||||
__tablename__ = "token_account"
|
||||
id = Column(Integer, primary_key=True)
|
||||
balance = Column(Integer, default=1250)
|
||||
|
||||
|
||||
class TokenPlan(Base):
|
||||
__tablename__ = "token_plans"
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String, default="")
|
||||
amount = Column(Integer, default=0)
|
||||
price = Column(Float, default=0)
|
||||
badge = Column(String, default="")
|
||||
desc = Column(String, default="")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"amount": self.amount,
|
||||
"price": self.price,
|
||||
"badge": self.badge,
|
||||
"desc": self.desc,
|
||||
}
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""会会用户 ↔ 本地用户体系映射(短信验证码登录落库)"""
|
||||
|
||||
__tablename__ = "users"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
huihui_user_id = Column(String, default="", index=True) # 会会 userId(唯一标识)
|
||||
phone = Column(String, default="", index=True)
|
||||
nickname = Column(String, default="")
|
||||
avatar_url = Column(String, default="")
|
||||
huihui_token = Column(String, default="") # 会会 access_token
|
||||
app_token = Column(String, default="") # 本系统会话 token
|
||||
last_login_at = Column(DateTime)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"huihuiUserId": self.huihui_user_id,
|
||||
"phone": self.phone,
|
||||
"nickname": self.nickname,
|
||||
"avatarUrl": self.avatar_url,
|
||||
"createdAt": _iso(self.created_at),
|
||||
"lastLoginAt": _iso(self.last_login_at),
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
pydantic
|
||||
python-multipart
|
||||
httpx
|
||||
pypdf
|
||||
python-docx
|
||||
openpyxl
|
||||
redis>=5.0
|
||||
apscheduler>=3.10
|
||||
@@ -0,0 +1,6 @@
|
||||
def ok(data=None, message="success"):
|
||||
return {"code": 200, "message": message, "data": data}
|
||||
|
||||
|
||||
def fail(message="error", code=400):
|
||||
return {"code": code, "message": message, "data": None}
|
||||
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Authorization
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["授权"])
|
||||
|
||||
|
||||
@router.get("/avatar/{avatar_id}/authorizations")
|
||||
def list_auth(avatar_id: str, db: Session = Depends(get_db)):
|
||||
# demo:返回全部授权(忽略具体 avatar 绑定,便于联调)
|
||||
items = db.query(Authorization).order_by(Authorization.created_at.desc()).all()
|
||||
return ok([a.to_dict() for a in items])
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/authorizations")
|
||||
def update_auth(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
auth_id = payload.get("id")
|
||||
if not auth_id:
|
||||
return fail("缺少授权 id", 400)
|
||||
a = db.query(Authorization).filter(Authorization.id == auth_id).first()
|
||||
if not a:
|
||||
return fail("授权不存在", 404)
|
||||
if "status" in payload:
|
||||
a.status = payload["status"]
|
||||
if "permissions" in payload:
|
||||
a.permissions = payload["permissions"]
|
||||
db.commit()
|
||||
items = db.query(Authorization).order_by(Authorization.created_at.desc()).all()
|
||||
return ok([x.to_dict() for x in items])
|
||||
@@ -0,0 +1,137 @@
|
||||
from fastapi import APIRouter, Depends, Body, Header, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
import uuid
|
||||
import mimetypes
|
||||
|
||||
from database import get_db
|
||||
from routers.knowledge import UPLOAD_DIR
|
||||
from models import Avatar, KnowledgeDoc, KnowledgeChunk, QAPair, Authorization, User
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["分身"])
|
||||
|
||||
|
||||
def _resolve_user(authorization: str | None, db: Session):
|
||||
"""从 Authorization: Bearer <app_token> 解析当前登录用户"""
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
return db.query(User).filter(User.app_token == token).first()
|
||||
|
||||
|
||||
@router.get("/avatar")
|
||||
def list_avatars(page: int = 1, limit: int = 20, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
# 仅返回当前登录用户自己的分身;未登录返回空,避免看到种子/他人数据
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
return ok({"data": [], "total": 0})
|
||||
q = db.query(Avatar).filter(Avatar.owner_id == user.huihui_user_id)
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(Avatar.created_at.desc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return ok({"data": [a.to_dict() for a in items], "total": total})
|
||||
|
||||
|
||||
@router.get("/avatar/{avatar_id}")
|
||||
def get_avatar(avatar_id: str, db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.post("/avatar")
|
||||
def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _resolve_user(authorization, db)
|
||||
a = Avatar(
|
||||
owner_id=user.huihui_user_id if user else "",
|
||||
name=payload.get("name", "未命名分身"),
|
||||
display_name=payload.get("displayName", "") or payload.get("display_name", ""),
|
||||
description=payload.get("description", ""),
|
||||
photo_url=payload.get("photoUrl", "") or payload.get("photo_url", ""),
|
||||
emoji=payload.get("emoji", "🤖"),
|
||||
status=payload.get("status", "active"),
|
||||
token_balance=payload.get("tokenBalance", 0),
|
||||
config=payload.get("config", {}) or {},
|
||||
)
|
||||
db.add(a)
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}")
|
||||
def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
mapping = {
|
||||
"displayName": "display_name",
|
||||
"photoUrl": "photo_url",
|
||||
"tokenBalance": "token_balance",
|
||||
}
|
||||
for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"):
|
||||
if key in payload:
|
||||
col = mapping.get(key, key)
|
||||
setattr(a, col, payload[key])
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}")
|
||||
def delete_avatar(avatar_id: str, db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
# 级联清理关联数据,避免孤儿记录
|
||||
db.query(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete()
|
||||
db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).delete()
|
||||
db.query(QAPair).filter(QAPair.avatar_id == avatar_id).delete()
|
||||
db.query(Authorization).filter(Authorization.avatar_id == avatar_id).delete()
|
||||
db.delete(a)
|
||||
db.commit()
|
||||
return ok({"success": True})
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/photo")
|
||||
async def upload_avatar_photo(
|
||||
avatar_id: str,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""上传数字分身头像"""
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
|
||||
# 验证文件类型
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
return fail("仅支持图片文件", 400)
|
||||
|
||||
file_bytes = await file.read()
|
||||
if len(file_bytes) > 5 * 1024 * 1024:
|
||||
return fail("头像文件不能超过5MB", 400)
|
||||
|
||||
# 保存到 uploads 目录
|
||||
ext = mimetypes.guess_extension(file.content_type) or ".jpg"
|
||||
filename = f"avatar-{uuid.uuid4().hex}{ext}"
|
||||
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
|
||||
os.makedirs(avatar_dir, exist_ok=True)
|
||||
file_path = os.path.join(avatar_dir, filename)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_bytes)
|
||||
|
||||
# 更新数据库
|
||||
photo_url = f"/api/files/{avatar_id}/{filename}"
|
||||
a.photo_url = photo_url
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
|
||||
return ok(a.to_dict())
|
||||
@@ -0,0 +1,205 @@
|
||||
import difflib
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import embeddings
|
||||
from database import get_db
|
||||
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["数字分身聊天"])
|
||||
|
||||
CHAT_API_URL = os.getenv("CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
CHAT_API_KEY = os.getenv("CHAT_API_KEY", "")
|
||||
CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen-plus")
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
MAX_HISTORY_MESSAGES = 10
|
||||
QA_SIMILARITY_THRESHOLD = 0.86
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str = Field(pattern="^(user|assistant)$")
|
||||
content: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
|
||||
|
||||
|
||||
class ChatIn(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
|
||||
history: list[ChatMessage] = Field(default_factory=list, max_length=MAX_HISTORY_MESSAGES)
|
||||
|
||||
|
||||
def _resolve_user(authorization: str | None, db: Session):
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
return db.query(User).filter(User.app_token == token).first()
|
||||
|
||||
|
||||
def _require_owned_avatar(db: Session, avatar_id: str, authorization: str | None):
|
||||
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not avatar:
|
||||
raise HTTPException(status_code=404, detail="分身不存在")
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
if avatar.owner_id and avatar.owner_id != user.huihui_user_id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该分身")
|
||||
return avatar
|
||||
|
||||
|
||||
def _normalize_question(value: str) -> str:
|
||||
value = (value or "").strip().lower()
|
||||
value = re.sub(r"\s+", "", value)
|
||||
return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》"))
|
||||
|
||||
|
||||
def _match_standard_qa(question: str, qa_pairs: list[Any]):
|
||||
normalized = _normalize_question(question)
|
||||
if not normalized:
|
||||
return None
|
||||
enabled = [qa for qa in qa_pairs if getattr(qa, "enabled", True)]
|
||||
for qa in enabled:
|
||||
if _normalize_question(getattr(qa, "question", "")) == normalized:
|
||||
return qa
|
||||
best = None
|
||||
best_score = 0.0
|
||||
for qa in enabled:
|
||||
candidate = _normalize_question(getattr(qa, "question", ""))
|
||||
if not candidate:
|
||||
continue
|
||||
score = difflib.SequenceMatcher(None, normalized, candidate).ratio()
|
||||
if score > best_score:
|
||||
best, best_score = qa, score
|
||||
return best if best_score >= QA_SIMILARITY_THRESHOLD else None
|
||||
|
||||
|
||||
def _config(avatar: Avatar) -> dict:
|
||||
config = getattr(avatar, "config", None) or {}
|
||||
return {
|
||||
"replyStyle": config.get("replyStyle", "professional"),
|
||||
"creativity": max(0, min(100, int(config.get("creativity", 50)))),
|
||||
"rigor": max(0, min(100, int(config.get("rigor", 50)))),
|
||||
"humor": max(0, min(100, int(config.get("humor", 30)))),
|
||||
"responseLength": config.get("responseLength", "medium"),
|
||||
"systemPrompt": (config.get("systemPrompt", "") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_hits: list[dict]) -> list[dict]:
|
||||
config = _config(avatar)
|
||||
knowledge = "\n".join(
|
||||
f"[{hit.get('filename', '知识库')}] {hit.get('snippet', '')}"
|
||||
for hit in knowledge_hits
|
||||
if hit.get("snippet")
|
||||
)
|
||||
system = (
|
||||
"你是用户的专属数字分身。请基于已提供的知识库回答,不要编造事实;"
|
||||
f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100;"
|
||||
f"幽默感:{config['humor']}/100;回复长度:{config['responseLength']}。"
|
||||
)
|
||||
if config["systemPrompt"]:
|
||||
system += f"\n额外系统提示词:{config['systemPrompt']}"
|
||||
if knowledge:
|
||||
system += f"\n以下是可参考的知识库内容:\n{knowledge}"
|
||||
messages = [{"role": "system", "content": system}]
|
||||
for item in history[-MAX_HISTORY_MESSAGES:]:
|
||||
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
|
||||
messages.append({"role": "user", "content": question.strip()})
|
||||
return messages
|
||||
|
||||
|
||||
def _search_knowledge(db: Session, avatar_id: str, question: str, top_k: int = 5) -> list[dict]:
|
||||
chunks = db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).all()
|
||||
if not chunks:
|
||||
return []
|
||||
qvec = embeddings.embed([question])[0]
|
||||
scored = []
|
||||
for chunk in chunks:
|
||||
try:
|
||||
vector = __import__("json").loads(chunk.vector)
|
||||
except Exception:
|
||||
continue
|
||||
scored.append((embeddings.cosine(qvec, vector), chunk))
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
results = []
|
||||
for score, chunk in scored[: max(1, top_k)]:
|
||||
doc = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == chunk.doc_id).first()
|
||||
results.append({
|
||||
"docId": chunk.doc_id,
|
||||
"filename": doc.filename if doc else "",
|
||||
"fileType": doc.file_type if doc else "",
|
||||
"snippet": chunk.content[:120] + ("…" if len(chunk.content) > 120 else ""),
|
||||
"score": round(score, 4),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _call_qwen(messages: list[dict], temperature: float) -> str:
|
||||
if not CHAT_API_KEY:
|
||||
raise RuntimeError("Qwen 模型服务未配置 CHAT_API_KEY")
|
||||
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
|
||||
payload = {
|
||||
"model": CHAT_MODEL,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
try:
|
||||
response = httpx.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {CHAT_API_KEY}"},
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
answer = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
|
||||
raise RuntimeError("Qwen 模型服务暂时不可用") from exc
|
||||
if not isinstance(answer, str) or not answer.strip():
|
||||
raise RuntimeError("Qwen 模型没有返回有效回答")
|
||||
return answer.strip()
|
||||
|
||||
|
||||
def _resolve_reply(
|
||||
db: Session,
|
||||
avatar: Avatar,
|
||||
question: str,
|
||||
history: list[Any],
|
||||
*,
|
||||
qa_pairs: list[Any] | None = None,
|
||||
search_fn: Callable[..., list[dict]] | None = None,
|
||||
model_client: Callable[..., str] | None = None,
|
||||
) -> dict:
|
||||
if qa_pairs is None:
|
||||
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
||||
matched = _match_standard_qa(question, qa_pairs)
|
||||
if matched:
|
||||
return {"answer": matched.answer, "source": "qa", "references": []}
|
||||
|
||||
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query))
|
||||
hits = search_fn(question, avatar.id)
|
||||
messages = _build_prompt(avatar, history, question, hits)
|
||||
config = _config(avatar)
|
||||
temperature = 0.2 + config["creativity"] / 100 * 0.6
|
||||
model_client = model_client or _call_qwen
|
||||
answer = model_client(messages=messages, temperature=temperature)
|
||||
return {
|
||||
"answer": answer,
|
||||
"source": "knowledge" if hits else "qwen",
|
||||
"references": hits,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/chat")
|
||||
def chat(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
||||
try:
|
||||
return ok(_resolve_reply(db, avatar, body.message, body.history))
|
||||
except RuntimeError as exc:
|
||||
return fail(str(exc), code=502)
|
||||
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
会会短信验证码登录代理(真实开放平台对接)
|
||||
──────────────────────────────────────────────
|
||||
严格按会会开放平台 sign.js 签名范式:
|
||||
- 公共字段 appId/accessId/timestamp(12小时制hh)/signType/signVersion/accessSecret/nonce 合并业务参数
|
||||
- 过滤空值 → 字典序排序 → k=v& 拼接 → 末尾追加 accessSecret=secretKey → MD5/SHA256 大写
|
||||
- 认证服务基址 / appId / accessId / accessSecret / clientCode 走环境变量
|
||||
真实端点(来自 fat-open 网关 usercenter 的 Swagger):
|
||||
- 发送验证码:POST {BASE}{HUIHUI_SMS_SEND_PATH} 默认 /open/mobile/sms/code (query 参数)
|
||||
- 短信登录: POST {BASE}{HUIHUI_SMS_LOGIN_PATH} 默认 /open/login/token,loginType=code
|
||||
登录成功后建/链本地 users 表(按会会 userId 唯一),签发本系统 app_token 作为会话。
|
||||
无真实凭证时仍可走 DEV_MOCK 兜底联调。
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import random
|
||||
import string
|
||||
import hashlib
|
||||
import httpx
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# 会会网关按北京时间(Asia/Shanghai, UTC+8)校验时间戳,容器默认 UTC 会导致签名被拒。
|
||||
# 用固定 +8 偏移(不依赖 tzdata,slim 镜像缺 IANA 库时会抛 ZoneInfoNotFoundError)。
|
||||
_CN_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
from database import get_db
|
||||
from models import User
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["会会账号"])
|
||||
|
||||
# ── 会会开放平台配置(环境变量)──
|
||||
AUTH_BASE_URL = os.getenv("HUIHUI_AUTH_BASE_URL", "https://fat-open.99hui.com/api/usercenter")
|
||||
APP_ID = os.getenv("HUIHUI_APP_ID", "")
|
||||
ACCESS_ID = os.getenv("HUIHUI_ACCESS_ID", "")
|
||||
ACCESS_SECRET = os.getenv("HUIHUI_ACCESS_SECRET", "")
|
||||
CLIENT_CODE = os.getenv("HUIHUI_CLIENT_CODE", "")
|
||||
SMS_SEND_PATH = os.getenv("HUIHUI_SMS_SEND_PATH", "/open/mobile/sms/code")
|
||||
SMS_LOGIN_PATH = os.getenv("HUIHUI_SMS_LOGIN_PATH", "/open/login/token")
|
||||
|
||||
# 临时开发态:无真实会会凭证时,本地模拟短信收发,便于端到端联调。
|
||||
DEV_MOCK = os.getenv("HUIHUI_DEV_MOCK", "false").lower() in ("1", "true", "yes")
|
||||
_mock_codes: dict[str, tuple[str, float]] = {}
|
||||
_MOCK_TTL = 300
|
||||
|
||||
|
||||
# ── 签名体系(完全对应 sign.js)──
|
||||
def _get_nonce() -> str:
|
||||
# 与会会 sign.js 一致:base36 随机串
|
||||
return "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
||||
|
||||
|
||||
def _get_timestamp() -> str:
|
||||
# 与会会 sign.js 一致:yyyyMMddHHmmss(24 小时制大写 HH)。
|
||||
# 实测:会会 common.format 走 24 小时制,用 12 小时制(%I)会被网关判"签名验证失败"。
|
||||
# 必须用北京时间,否则容器(UTC)生成的时间戳与会会网关校验窗口偏差 8h 被拒。
|
||||
return datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S")
|
||||
|
||||
|
||||
def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str:
|
||||
SIGN_KEY = "signature"
|
||||
SECRET_KEY = "accessSecret"
|
||||
keys = sorted(params.keys())
|
||||
parts = []
|
||||
for k in keys:
|
||||
if k in (SIGN_KEY, SECRET_KEY):
|
||||
continue
|
||||
v = params.get(k)
|
||||
if v is None or v == "" or v == []:
|
||||
continue
|
||||
if isinstance(v, list):
|
||||
continue
|
||||
parts.append(f"{k}={v}")
|
||||
sign_str = "&".join(parts) + f"&{SECRET_KEY}={secret_key}"
|
||||
if sign_type.upper() == "SHA256":
|
||||
return hashlib.sha256(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
def _build_form(extra: dict) -> dict:
|
||||
sign_type = "MD5"
|
||||
sign_version = "1.0"
|
||||
secret = ACCESS_SECRET
|
||||
base = {
|
||||
"appId": APP_ID,
|
||||
"accessId": ACCESS_ID,
|
||||
"timestamp": _get_timestamp(),
|
||||
"signType": sign_type,
|
||||
"signVersion": sign_version,
|
||||
"accessSecret": secret,
|
||||
"nonce": _get_nonce(),
|
||||
}
|
||||
base.update(extra)
|
||||
signature = _make_sign(base, secret, sign_type) if secret else ""
|
||||
base["signature"] = signature
|
||||
base.pop("accessSecret", None) # 不发送密钥
|
||||
return base
|
||||
|
||||
|
||||
def _pick(d: dict, *keys, default=""):
|
||||
for k in keys:
|
||||
if d.get(k) not in (None, ""):
|
||||
return d[k]
|
||||
return default
|
||||
|
||||
|
||||
def _cfg_ready() -> bool:
|
||||
return bool(AUTH_BASE_URL and APP_ID and ACCESS_ID and ACCESS_SECRET)
|
||||
|
||||
|
||||
def _call_huihui(path: str, params: dict, as_query: bool = False):
|
||||
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
|
||||
url = f"{AUTH_BASE_URL}{path}"
|
||||
with httpx.Client(timeout=30, follow_redirects=True) as c:
|
||||
if as_query:
|
||||
resp = c.post(url, params=params)
|
||||
else:
|
||||
resp = c.post(url, data=params)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return False, {"message": f"会会返回非JSON: {resp.text[:200]}"}, resp.status_code
|
||||
# 会会统一包装 {code, message, data}
|
||||
code = data.get("code")
|
||||
if resp.status_code == 200 and code in (0, 200, "0", "200"):
|
||||
return True, data, resp.status_code
|
||||
return False, data, resp.status_code
|
||||
|
||||
|
||||
@router.post("/huihui/sms/send")
|
||||
def sms_send(body: dict = Body(...)):
|
||||
"""请求会会发送短信验证码(真实开放平台 /open/mobile/sms/code)"""
|
||||
phone = (body.get("phone") or "").strip()
|
||||
if not phone or not phone.isdigit() or len(phone) != 11:
|
||||
return fail("请输入正确的 11 位手机号", 400)
|
||||
|
||||
# 临时开发态:本地模拟发码
|
||||
if DEV_MOCK and not _cfg_ready():
|
||||
code = str(random.randint(100000, 999999))
|
||||
_mock_codes[phone] = (code, datetime.now().timestamp() + _MOCK_TTL)
|
||||
return ok({"sent": True, "devCode": code, "dev": True})
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会短信服务未配置(缺少 HUIHUI_APP_ID / HUIHUI_ACCESS_ID / HUIHUI_ACCESS_SECRET)", 500)
|
||||
|
||||
# /open/mobile/sms/code:mobile 与公共字段均走 query
|
||||
form = _build_form({"mobile": phone})
|
||||
ok_flag, data, status = _call_huihui(SMS_SEND_PATH, form, as_query=True)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"发送失败(HTTP {status})", 502)
|
||||
return ok({"sent": True})
|
||||
|
||||
|
||||
@router.post("/huihui/sms/login")
|
||||
def sms_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
"""短信验证码登录:调会会 /open/login/token(loginType=code) 换取 access_token + userId,落库并签发本系统会话"""
|
||||
phone = (body.get("phone") or "").strip()
|
||||
code = (body.get("code") or "").strip()
|
||||
if not phone or not code:
|
||||
return fail("手机号或验证码缺失", 400)
|
||||
|
||||
# 临时开发态:本地校验模拟码
|
||||
if DEV_MOCK and not _cfg_ready():
|
||||
rec = _mock_codes.get(phone)
|
||||
if not rec or rec[1] < datetime.now().timestamp():
|
||||
return fail("验证码已失效,请重新获取", 401)
|
||||
if rec[0] != code:
|
||||
return fail("验证码错误", 401)
|
||||
_mock_codes.pop(phone, None)
|
||||
return _issue_session(db, phone, {
|
||||
"userId": f"dev_{phone}",
|
||||
"nickname": f"会会用户{phone[-4:]}",
|
||||
"avatarUrl": "",
|
||||
"token": f"dev_token_{phone}",
|
||||
})
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会登录服务未配置", 500)
|
||||
|
||||
extra = {
|
||||
"username": phone,
|
||||
"password": code,
|
||||
"loginType": "code",
|
||||
"grantType": "password",
|
||||
"isRegister": "true",
|
||||
}
|
||||
if CLIENT_CODE:
|
||||
extra["clientCode"] = CLIENT_CODE
|
||||
form = _build_form(extra)
|
||||
ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"登录失败(HTTP {status})", 401)
|
||||
|
||||
raw = data.get("data") or {}
|
||||
user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {}
|
||||
huihui_token = (
|
||||
_pick(raw, "accessToken", "access_token", "token")
|
||||
or _pick(user_info, "accessToken", "access_token", "token")
|
||||
)
|
||||
huihui_user_id = (
|
||||
_pick(raw, "openid", "userId", "uid", "openId", "id")
|
||||
or _pick(user_info, "openid", "userId", "uid", "openId", "id")
|
||||
)
|
||||
nickname = (
|
||||
_pick(raw, "nickName", "nickname", "name", "userName")
|
||||
or _pick(user_info, "nickName", "nickname", "name", "userName")
|
||||
)
|
||||
avatar_url = (
|
||||
_pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
)
|
||||
if not huihui_user_id:
|
||||
return fail("会会未返回用户标识", 502)
|
||||
|
||||
return _issue_session(db, phone, {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": nickname,
|
||||
"avatarUrl": avatar_url,
|
||||
"token": huihui_token,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/huihui/pwd/login")
|
||||
def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
"""账号密码登录:调会会 /open/login/token(loginType=password) 换取 access_token + userId,落库并签发本系统会话。
|
||||
与 news_service 既有对接完全一致:username=账号/手机号, password=密码, loginType=password, grantType=password, isRegister=false。
|
||||
"""
|
||||
account = (body.get("account") or "").strip()
|
||||
password = (body.get("password") or "").strip()
|
||||
if not account or not password:
|
||||
return fail("账号或密码缺失", 400)
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会登录服务未配置", 500)
|
||||
|
||||
extra = {
|
||||
"username": account,
|
||||
"password": password,
|
||||
"loginType": "password",
|
||||
"grantType": "password",
|
||||
"isRegister": "false",
|
||||
}
|
||||
if CLIENT_CODE:
|
||||
extra["clientCode"] = CLIENT_CODE
|
||||
form = _build_form(extra)
|
||||
ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"登录失败(HTTP {status})", 401)
|
||||
|
||||
raw = data.get("data") or {}
|
||||
user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {}
|
||||
huihui_token = (
|
||||
_pick(raw, "accessToken", "access_token", "token")
|
||||
or _pick(user_info, "accessToken", "access_token", "token")
|
||||
)
|
||||
huihui_user_id = (
|
||||
_pick(raw, "openid", "userId", "uid", "openId", "id")
|
||||
or _pick(user_info, "openid", "userId", "uid", "openId", "id")
|
||||
)
|
||||
nickname = (
|
||||
_pick(raw, "nickName", "nickname", "name", "userName")
|
||||
or _pick(user_info, "nickName", "nickname", "name", "userName")
|
||||
)
|
||||
avatar_url = (
|
||||
_pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
)
|
||||
if not huihui_user_id:
|
||||
return fail("会会未返回用户标识", 502)
|
||||
|
||||
# 账号即手机号时记录,便于资料展示;非手机号(用户名)则不覆盖已有 phone
|
||||
phone = account if (account.isdigit() and len(account) == 11) else ""
|
||||
return _issue_session(db, phone, {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": nickname,
|
||||
"avatarUrl": avatar_url,
|
||||
"token": huihui_token,
|
||||
})
|
||||
|
||||
|
||||
def _issue_session(db: Session, phone: str, info: dict):
|
||||
"""建/链本地用户并签发本系统会话 token"""
|
||||
huihui_user_id = info.get("userId", "")
|
||||
user = db.query(User).filter(User.huihui_user_id == huihui_user_id).first()
|
||||
if not user:
|
||||
user = User(huihui_user_id=huihui_user_id)
|
||||
if phone:
|
||||
user.phone = phone
|
||||
if info.get("nickname"):
|
||||
user.nickname = info["nickname"]
|
||||
if info.get("avatarUrl"):
|
||||
user.avatar_url = info["avatarUrl"]
|
||||
user.huihui_token = info.get("token", "")
|
||||
user.app_token = uuid.uuid4().hex
|
||||
user.last_login_at = datetime.now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return ok({
|
||||
"token": user.app_token,
|
||||
"user": user.to_dict(),
|
||||
"huihui": {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": info.get("nickname", ""),
|
||||
"avatarUrl": info.get("avatarUrl", ""),
|
||||
"token": info.get("token", ""),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@router.get("/huihui/me")
|
||||
def me(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
"""当前登录用户信息(Bearer app_token)"""
|
||||
if not authorization:
|
||||
return fail("未登录", 401)
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
user = db.query(User).filter(User.app_token == token).first()
|
||||
if not user:
|
||||
return fail("会话无效或已过期", 401)
|
||||
return ok(user.to_dict())
|
||||
|
||||
|
||||
@router.post("/huihui/logout")
|
||||
def logout(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
"""退出登录(作废 app_token)"""
|
||||
if authorization:
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
user = db.query(User).filter(User.app_token == token).first()
|
||||
if user:
|
||||
user.app_token = ""
|
||||
db.commit()
|
||||
return ok({"success": True})
|
||||
@@ -0,0 +1,278 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import KnowledgeDoc, QAPair, KnowledgeChunk, Avatar, User
|
||||
from responses import ok, fail
|
||||
import embeddings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
ALLOWED_EXT = {".md", ".txt", ".pdf", ".doc", ".docx", ".xlsx"}
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
class QAIn(BaseModel):
|
||||
question: str = ""
|
||||
answer: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class EnabledIn(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
def _resolve_user(authorization: str | None, db: Session):
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
return db.query(User).filter(User.app_token == token).first()
|
||||
|
||||
|
||||
def _require_owned_avatar(db: Session, avatar_id: str, authorization: str | None):
|
||||
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not avatar:
|
||||
raise HTTPException(status_code=404, detail="avatar not found")
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
if avatar.owner_id and avatar.owner_id != user.huihui_user_id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该分身")
|
||||
return avatar
|
||||
|
||||
|
||||
# ---------------- Documents ----------------
|
||||
@router.get("/avatar/{avatar_id}/knowledge/docs")
|
||||
def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
docs = (
|
||||
db.query(KnowledgeDoc)
|
||||
.filter(KnowledgeDoc.avatar_id == avatar_id)
|
||||
.order_by(KnowledgeDoc.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return ok([d.to_dict() for d in docs])
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/knowledge/docs")
|
||||
async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||
if ext not in ALLOWED_EXT:
|
||||
return fail(f"不支持的文件类型:{ext or '空'},仅支持 md/txt/pdf/doc/docx/xlsx", code=400)
|
||||
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
|
||||
os.makedirs(avatar_dir, exist_ok=True)
|
||||
stored = f"{uuid.uuid4().hex}{ext}"
|
||||
path = os.path.join(avatar_dir, stored)
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
return fail("文件不能超过 10MB", code=400)
|
||||
with open(path, "wb") as f:
|
||||
f.write(content)
|
||||
doc = KnowledgeDoc(
|
||||
avatar_id=avatar_id,
|
||||
filename=file.filename,
|
||||
file_type=ext.lstrip("."),
|
||||
file_size=len(content),
|
||||
file_url=f"/api/files/{avatar_id}/{stored}",
|
||||
status="parsing",
|
||||
)
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
|
||||
# 向量化:抽取文本 -> 分块 -> 调第三方/本地嵌入 -> 存切片
|
||||
try:
|
||||
text = embeddings.extract_text(path, ext)
|
||||
chunks = embeddings.chunk_text(text)
|
||||
if chunks:
|
||||
vectors = embeddings.embed(chunks)
|
||||
for i, (c, v) in enumerate(zip(chunks, vectors)):
|
||||
db.add(
|
||||
KnowledgeChunk(
|
||||
doc_id=doc.id,
|
||||
avatar_id=avatar_id,
|
||||
content=c,
|
||||
vector=json.dumps(v),
|
||||
chunk_index=i,
|
||||
embedding_model=embeddings.MODEL,
|
||||
)
|
||||
)
|
||||
doc.vectorized = True
|
||||
doc.embedding_model = embeddings.MODEL
|
||||
doc.chunk_count = len(chunks)
|
||||
doc.vectorized_at = datetime.now(timezone.utc)
|
||||
doc.status = "ready"
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
except Exception as e:
|
||||
print("vectorize failed:", e)
|
||||
doc.status = "ready" # 上传成功但向量化失败,仍可展示
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
|
||||
return ok(doc.to_dict())
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}/knowledge/docs/{doc_id}")
|
||||
def delete_doc(avatar_id: str, doc_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
doc = (
|
||||
db.query(KnowledgeDoc)
|
||||
.filter(KnowledgeDoc.id == doc_id, KnowledgeDoc.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not doc:
|
||||
return fail("文档不存在", code=404)
|
||||
# 级联删除切片
|
||||
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == doc_id).delete()
|
||||
try:
|
||||
fp = os.path.join(UPLOAD_DIR, avatar_id, os.path.basename(doc.file_url))
|
||||
if os.path.exists(fp):
|
||||
os.remove(fp)
|
||||
except Exception:
|
||||
pass
|
||||
db.delete(doc)
|
||||
db.commit()
|
||||
return ok({"id": doc_id})
|
||||
|
||||
|
||||
# ---------------- 向量检索 ----------------
|
||||
@router.get("/avatar/{avatar_id}/knowledge/search")
|
||||
def search_knowledge(avatar_id: str, q: str = "", top_k: int = 5, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (q or "").strip()
|
||||
if not q:
|
||||
return ok([])
|
||||
chunks = (
|
||||
db.query(KnowledgeChunk)
|
||||
.filter(KnowledgeChunk.avatar_id == avatar_id)
|
||||
.all()
|
||||
)
|
||||
if not chunks:
|
||||
return ok([])
|
||||
qvec = embeddings.embed([q])[0]
|
||||
scored = []
|
||||
for c in chunks:
|
||||
try:
|
||||
vec = json.loads(c.vector)
|
||||
except Exception:
|
||||
continue
|
||||
scored.append((embeddings.cosine(qvec, vec), c))
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
results = []
|
||||
for score, c in scored[: max(1, top_k)]:
|
||||
doc = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == c.doc_id).first()
|
||||
snippet = c.content[:120] + ("…" if len(c.content) > 120 else "")
|
||||
results.append(
|
||||
{
|
||||
"docId": c.doc_id,
|
||||
"filename": doc.filename if doc else "",
|
||||
"fileType": doc.file_type if doc else "",
|
||||
"snippet": snippet,
|
||||
"score": round(score, 4),
|
||||
}
|
||||
)
|
||||
return ok(results)
|
||||
|
||||
|
||||
# ---------------- Standard Q&A pairs ----------------
|
||||
@router.get("/avatar/{avatar_id}/knowledge/qa")
|
||||
def list_qa(avatar_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
items = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.avatar_id == avatar_id)
|
||||
.order_by(QAPair.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return ok([q.to_dict() for q in items])
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/knowledge/qa")
|
||||
def create_qa(avatar_id: str, body: QAIn, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = QAPair(
|
||||
avatar_id=avatar_id,
|
||||
question=body.question,
|
||||
answer=body.answer,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
db.add(q)
|
||||
db.commit()
|
||||
db.refresh(q)
|
||||
return ok(q.to_dict())
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/knowledge/qa/{qa_id}")
|
||||
def update_qa(avatar_id: str, qa_id: str, body: QAIn, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not q:
|
||||
return fail("问答对不存在", code=404)
|
||||
q.question = body.question
|
||||
q.answer = body.answer
|
||||
q.enabled = body.enabled
|
||||
db.commit()
|
||||
db.refresh(q)
|
||||
return ok(q.to_dict())
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/knowledge/qa/{qa_id}/enabled")
|
||||
def set_qa_enabled(avatar_id: str, qa_id: str, body: EnabledIn, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not q:
|
||||
return fail("问答对不存在", code=404)
|
||||
q.enabled = bool(body.enabled)
|
||||
db.commit()
|
||||
db.refresh(q)
|
||||
return ok(q.to_dict())
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}/knowledge/qa/{qa_id}")
|
||||
def delete_qa(avatar_id: str, qa_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not q:
|
||||
return fail("问答对不存在", code=404)
|
||||
db.delete(q)
|
||||
db.commit()
|
||||
return ok({"id": qa_id})
|
||||
|
||||
|
||||
# ---------------- HuiHui user profile (mock; plug real interface via HUIHUI_USER_API) ----------------
|
||||
@router.get("/user/profile")
|
||||
def user_profile():
|
||||
# 接入真实会会接口:设置环境变量 HUIHUI_USER_API 后在此请求并映射字段
|
||||
api = os.getenv("HUIHUI_USER_API")
|
||||
if api:
|
||||
# TODO: 调用会会用户接口,返回 { userId, nickname, avatarUrl }
|
||||
pass
|
||||
return ok({
|
||||
"userId": "hh_10001",
|
||||
"nickname": "会会用户",
|
||||
"avatarUrl": "https://api.dicebear.com/7.x/initials/svg?seed=HuiHui&backgroundColor=F97316",
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Organization
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["组织"])
|
||||
|
||||
|
||||
@router.get("/organizations")
|
||||
def list_orgs(page: int = 1, limit: int = 20, db: Session = Depends(get_db)):
|
||||
q = db.query(Organization)
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(Organization.created_at.desc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return ok({"data": [o.to_dict() for o in items], "total": total})
|
||||
|
||||
|
||||
@router.post("/organizations")
|
||||
def create_org(payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
o = Organization(
|
||||
name=payload.get("name", "未命名组织"),
|
||||
description=payload.get("desc", "") or payload.get("description", ""),
|
||||
emoji=payload.get("emoji", "🏢"),
|
||||
org_type=payload.get("type", "") or payload.get("orgType", "team"),
|
||||
)
|
||||
db.add(o)
|
||||
db.commit()
|
||||
db.refresh(o)
|
||||
return ok(o.to_dict())
|
||||
@@ -0,0 +1,41 @@
|
||||
"""分身接管配置 API"""
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Authorization
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["分身接管"])
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/authorizations/takeover")
|
||||
def update_takeover_config(
|
||||
avatar_id: str,
|
||||
payload: dict = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""更新分身接管配置"""
|
||||
auth_id = payload.get("authorizationId") or payload.get("authorization_id")
|
||||
if not auth_id:
|
||||
return fail("缺少 authorization_id", 400)
|
||||
|
||||
auth = db.query(Authorization).filter(Authorization.id == auth_id).first()
|
||||
if not auth:
|
||||
return fail("授权不存在", 404)
|
||||
|
||||
if "takeover_enabled" in payload:
|
||||
auth.takeover_enabled = payload["takeover_enabled"]
|
||||
if "takeover_mode" in payload:
|
||||
mode = payload["takeover_mode"]
|
||||
if mode not in ("immediate", "delayed"):
|
||||
return fail("takeover_mode 必须是 immediate 或 delayed", 400)
|
||||
auth.takeover_mode = mode
|
||||
if "takeover_delay_seconds" in payload:
|
||||
delay = payload["takeover_delay_seconds"]
|
||||
if not isinstance(delay, int) or delay < 5:
|
||||
return fail("takeover_delay_seconds 必须 >= 5", 400)
|
||||
auth.takeover_delay_seconds = delay
|
||||
|
||||
db.commit()
|
||||
return ok(auth.to_dict())
|
||||
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import TokenAccount, TokenPlan
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["Token"])
|
||||
|
||||
|
||||
@router.get("/token/balance")
|
||||
def balance(db: Session = Depends(get_db)):
|
||||
acc = db.query(TokenAccount).first()
|
||||
return ok({"balance": acc.balance if acc else 0})
|
||||
|
||||
|
||||
@router.get("/token/plans")
|
||||
def plans(db: Session = Depends(get_db)):
|
||||
items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
|
||||
return ok([p.to_dict() for p in items])
|
||||
|
||||
|
||||
@router.post("/token/charge")
|
||||
def charge(payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
plan_id = payload.get("planId")
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
return fail("套餐不存在", 404)
|
||||
acc = db.query(TokenAccount).first()
|
||||
if not acc:
|
||||
acc = TokenAccount(balance=0)
|
||||
db.add(acc)
|
||||
db.commit()
|
||||
db.refresh(acc)
|
||||
acc.balance += plan.amount
|
||||
db.commit()
|
||||
return ok({"balance": acc.balance, "charged": plan.amount})
|
||||
@@ -0,0 +1,76 @@
|
||||
"""盒子 IM 客户端 — 封装网易云信 IM 接口调用"""
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class BoxIMClient:
|
||||
"""盒子 IM 客户端,通过会会平台网关调用网易云信 IM"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.base_url = config.get("HUIHUI_IM_BASE_URL", "http://192.168.1.200:60040")
|
||||
self.app_id = config.get("HUIHUI_APP_ID", "")
|
||||
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
|
||||
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
|
||||
|
||||
def _build_sign_params(self, extra: dict) -> dict:
|
||||
"""构建带签名的请求参数(复用 news_service 签名模式)"""
|
||||
nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S") # 24小时制
|
||||
params = {
|
||||
"appId": self.app_id,
|
||||
"accessId": self.access_id,
|
||||
"nonce": nonce,
|
||||
"timestamp": timestamp,
|
||||
**extra,
|
||||
}
|
||||
# 计算签名 — 排序 key, 过滤空值, 拼接后加 accessSecret, MD5 大写
|
||||
keys = sorted(params.keys())
|
||||
sign_parts = []
|
||||
for k in keys:
|
||||
if k in ("signature", "accessSecret"):
|
||||
continue
|
||||
v = params.get(k)
|
||||
if v and v != "" and v != []:
|
||||
sign_parts.append(f"{k}={v}")
|
||||
sign_str = "&".join(sign_parts) + f"&accessSecret={self.access_secret}"
|
||||
signature = hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
params["signature"] = signature
|
||||
params["signType"] = "MD5"
|
||||
params["signVersion"] = "1.0"
|
||||
return params
|
||||
|
||||
async def get_credentials(self, user_id: str) -> Optional[dict]:
|
||||
"""获取用户的网易云信 IM 凭证 (accid, token)"""
|
||||
params = self._build_sign_params({"userId": user_id})
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base_url}/box/netease",
|
||||
params=params,
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("code") in (0, 200):
|
||||
return data.get("data", {})
|
||||
return None
|
||||
|
||||
async def send_p2p_message(
|
||||
self, from_accid: str, to_accid: str, content: str
|
||||
) -> bool:
|
||||
"""发送单聊消息(文本)"""
|
||||
params = self._build_sign_params({
|
||||
"from": from_accid,
|
||||
"to": to_accid,
|
||||
"msgType": "text",
|
||||
"content": content,
|
||||
})
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base_url}/box/message/send/p2p",
|
||||
params=params,
|
||||
)
|
||||
data = r.json()
|
||||
return data.get("code") in (0, 200)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Takeover service — message listening, decision, reply execution."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import Avatar, Authorization
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TakeoverService:
|
||||
"""Service for handling avatar takeover — generating replies and sending them via IM."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
boxim_client: BoxIMClient,
|
||||
redis_client=None,
|
||||
):
|
||||
self.db = db
|
||||
self.boxim = boxim_client
|
||||
self.redis = redis_client
|
||||
self._chat_api_base = os.getenv(
|
||||
"TAKEOVER_CHAT_API_BASE", "http://localhost:8000/api"
|
||||
)
|
||||
|
||||
def check_takeover_enabled(
|
||||
self, owner_huihui_id: str, from_user_id: str
|
||||
) -> Optional[Authorization]:
|
||||
"""Check whether takeover is enabled for the given target user."""
|
||||
avatar = self.db.query(Avatar).filter(Avatar.owner_id == owner_huihui_id).first()
|
||||
if not avatar:
|
||||
return None
|
||||
|
||||
auth = (
|
||||
self.db.query(Authorization)
|
||||
.filter(Authorization.avatar_id == avatar.id)
|
||||
.filter(Authorization.target_id == from_user_id)
|
||||
.filter(Authorization.takeover_enabled == True)
|
||||
.first()
|
||||
)
|
||||
return auth if auth and auth.takeover_enabled else None
|
||||
|
||||
async def generate_reply(self, avatar_id: str, message: str) -> str:
|
||||
"""Call the avatar chat endpoint to generate a reply."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{self._chat_api_base}/avatar/{avatar_id}/chat",
|
||||
json={"message": message, "history": []},
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("code") in (0, 200):
|
||||
return data.get("data", {}).get("answer", "")
|
||||
logger.warning(f"Avatar chat API returned error code: {data}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to call avatar chat API: {e}")
|
||||
return ""
|
||||
|
||||
async def execute_takeover(self, auth: Authorization, message: dict) -> bool:
|
||||
"""Execute takeover: generate a reply and send it as the owner via IM."""
|
||||
try:
|
||||
# Resolve owner through Avatar model
|
||||
avatar = self.db.query(Avatar).filter(Avatar.id == auth.avatar_id).first()
|
||||
if not avatar:
|
||||
logger.warning(f"Avatar not found: {auth.avatar_id}")
|
||||
return False
|
||||
|
||||
owner_huihui_id = avatar.owner_id
|
||||
credentials = await self.boxim.get_credentials(owner_huihui_id)
|
||||
if not credentials:
|
||||
logger.warning(f"Cannot obtain IM credentials for owner: {owner_huihui_id}")
|
||||
return False
|
||||
|
||||
reply = await self.generate_reply(auth.avatar_id, message.get("content", ""))
|
||||
if not reply:
|
||||
logger.warning("Avatar did not generate a reply")
|
||||
return False
|
||||
|
||||
success = await self.boxim.send_p2p_message(
|
||||
from_accid=credentials["accid"],
|
||||
to_accid=message.get("from_accid", ""),
|
||||
content=reply,
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Takeover reply sent successfully: {reply[:50]}...")
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"Takeover execution failed: {e}")
|
||||
return False
|
||||
|
||||
def enqueue_delayed_message(self, auth: Authorization, message: dict):
|
||||
"""Write a message into the Redis delayed queue (TTL = delay + 10s buffer)."""
|
||||
if not self.redis:
|
||||
logger.warning("Redis not configured, degrading to immediate takeover")
|
||||
return
|
||||
|
||||
avatar = self.db.query(Avatar).filter(Avatar.id == auth.avatar_id).first()
|
||||
owner_huihui_id = avatar.owner_id if avatar else ""
|
||||
key = f"takeover:delayed:{auth.target_id}:{message.get('msg_id', '')}"
|
||||
value = json.dumps({
|
||||
"avatar_id": auth.avatar_id,
|
||||
"from_accid": message.get("from_accid", ""),
|
||||
"content": message.get("content", ""),
|
||||
"owner_huihui_id": owner_huihui_id,
|
||||
})
|
||||
self.redis.setex(key, auth.takeover_delay_seconds + 10, value)
|
||||
logger.info(f"Message enqueued to delayed queue: {key}")
|
||||
|
||||
async def process_delayed_queue(self):
|
||||
"""Process expired messages from the delayed queue.
|
||||
|
||||
Scans Redis keys matching the takeover:delayed: pattern and dispatches
|
||||
each to execute_takeover after resolving the Authorization.
|
||||
"""
|
||||
if not self.redis:
|
||||
return
|
||||
try:
|
||||
pattern = "takeover:delayed:*"
|
||||
keys = self.redis.keys(pattern)
|
||||
for key in keys:
|
||||
raw = self.redis.get(key)
|
||||
if not raw:
|
||||
continue
|
||||
data = json.loads(raw)
|
||||
auth = (
|
||||
self.db.query(Authorization)
|
||||
.filter(Authorization.target_id == key.split(":")[2])
|
||||
.first()
|
||||
)
|
||||
if auth:
|
||||
message = {
|
||||
"msg_id": key.split(":")[-1],
|
||||
"from_accid": data.get("from_accid", ""),
|
||||
"content": data.get("content", ""),
|
||||
}
|
||||
await self.execute_takeover(auth, message)
|
||||
self.redis.delete(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process delayed queue: {e}")
|
||||
|
||||
async def poll_and_process_messages(self):
|
||||
"""Periodic polling job: fetch unread messages and process each."""
|
||||
try:
|
||||
messages = await self.fetch_unread_messages()
|
||||
for msg in messages:
|
||||
await self.process_message(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"poll_and_process_messages failed: {e}")
|
||||
|
||||
async def fetch_unread_messages(self) -> list:
|
||||
"""Fetch unread messages from Box IM. Stub — replace with real API call."""
|
||||
logger.debug("fetch_unread_messages: no real API wired yet")
|
||||
return []
|
||||
|
||||
async def process_message(self, message: dict):
|
||||
"""Process a single message: check takeover, dispatch immediate or delayed."""
|
||||
owner_id = message.get("owner_huihui_id", "")
|
||||
from_id = message.get("from_accid", "")
|
||||
|
||||
auth = self.check_takeover_enabled(owner_id, from_id)
|
||||
if not auth:
|
||||
return
|
||||
|
||||
if auth.takeover_mode == "immediate":
|
||||
await self.execute_takeover(auth, message)
|
||||
else:
|
||||
self.enqueue_delayed_message(auth, message)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import pytest
|
||||
from database import init_db, SessionLocal
|
||||
from models import Authorization
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def setup_database():
|
||||
"""Initialize DB tables and seed an Authorization row."""
|
||||
init_db()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing = db.query(Authorization).first()
|
||||
if existing is None:
|
||||
auth = Authorization(
|
||||
id="test-auth-1",
|
||||
avatar_id="test-avatar-1",
|
||||
target_type="user",
|
||||
target_id="test-user-1",
|
||||
target_name="Test User",
|
||||
permissions=["read", "write"],
|
||||
status="active",
|
||||
)
|
||||
db.add(auth)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Tests for the Box IM client (Netease Yunxin gateway wrapper)."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
return {
|
||||
"HUIHUI_IM_BASE_URL": "http://192.168.1.200:60040",
|
||||
"HUIHUI_APP_ID": "test_app",
|
||||
"HUIHUI_ACCESS_ID": "test_access",
|
||||
"HUIHUI_ACCESS_SECRET": "test_secret",
|
||||
}
|
||||
|
||||
|
||||
def _make_mock_response(json_data: dict):
|
||||
"""Create a properly configured mock for httpx.Response."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = json_data
|
||||
return mock_response
|
||||
|
||||
|
||||
def _patch_httpx_client(json_data: dict):
|
||||
"""Patch httpx.AsyncClient so that `async with httpx.AsyncClient() as c: await c.post(...)` returns json_data."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = _make_mock_response(json_data)
|
||||
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__.return_value = mock_client
|
||||
mock_cm.__aexit__.return_value = None
|
||||
|
||||
return patch("httpx.AsyncClient", return_value=mock_cm)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_credentials(mock_config):
|
||||
"""get_credentials should return accid and token from the gateway response."""
|
||||
with _patch_httpx_client({"code": 200, "data": {"accid": "user123", "token": "tok_xyz"}}):
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
result = await client.get_credentials("user123")
|
||||
|
||||
assert result["accid"] == "user123"
|
||||
assert result["token"] == "tok_xyz"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_p2p_message_success(mock_config):
|
||||
"""send_p2p_message should return True when the gateway responds with code 200."""
|
||||
with _patch_httpx_client({"code": 200}):
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
result = await client.send_p2p_message("owner_acc", "target_acc", "Hello")
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_p2p_message_failure(mock_config):
|
||||
"""send_p2p_message should return False when the gateway responds with a non-200 code."""
|
||||
with _patch_httpx_client({"code": 500, "message": "error"}):
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
result = await client.send_p2p_message("owner_acc", "target_acc", "Hello")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_credentials_returns_none_on_error(mock_config):
|
||||
"""get_credentials should return None when the gateway responds with an error code."""
|
||||
with _patch_httpx_client({"code": 500, "message": "user not found"}):
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
result = await client.get_credentials("nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_build_sign_params_contains_required_fields(mock_config):
|
||||
"""_build_sign_params should produce appId, accessId, nonce, timestamp, signature, signType, signVersion."""
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
params = client._build_sign_params({"userId": "u1"})
|
||||
|
||||
assert "appId" in params
|
||||
assert "accessId" in params
|
||||
assert "nonce" in params
|
||||
assert "timestamp" in params
|
||||
assert "signature" in params
|
||||
assert params["signType"] == "MD5"
|
||||
assert params["signVersion"] == "1.0"
|
||||
assert len(params["nonce"]) == 12
|
||||
|
||||
|
||||
def test_build_sign_params_excludes_signature_and_accessSecret_from_signing_string(mock_config):
|
||||
"""signature and accessSecret must be excluded from the signing string to match news_service.py."""
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
|
||||
# Pass params that already contain a stale "signature" value
|
||||
params_with_stale_sig = client._build_sign_params({
|
||||
"userId": "u1",
|
||||
"signature": "OLD_STALE_SIG",
|
||||
})
|
||||
|
||||
# The returned signature must be freshly computed (32-char MD5 uppercase),
|
||||
# NOT the stale value we passed in.
|
||||
assert params_with_stale_sig["signature"] != "OLD_STALE_SIG"
|
||||
assert len(params_with_stale_sig["signature"]) == 32
|
||||
|
||||
# Calling with the same extra params but no stale signature should also work.
|
||||
params_clean = client._build_sign_params({"userId": "u1"})
|
||||
assert len(params_clean["signature"]) == 32
|
||||
|
||||
|
||||
def test_build_sign_params_signature_is_deterministic(mock_config):
|
||||
"""Same inputs should produce valid MD5 signatures."""
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
|
||||
params1 = client._build_sign_params({"userId": "u1"})
|
||||
params2 = client._build_sign_params({"userId": "u1"})
|
||||
|
||||
assert params1["signature"] is not None
|
||||
assert params2["signature"] is not None
|
||||
assert len(params1["signature"]) == 32 # MD5 hex length
|
||||
@@ -0,0 +1,95 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models import Avatar, User
|
||||
from routers.chat import _build_prompt, _match_standard_qa, _require_owned_avatar, _resolve_reply
|
||||
|
||||
|
||||
class ChatOrchestrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.avatar = SimpleNamespace(
|
||||
id="avatar-1",
|
||||
owner_id="huihui-user-1",
|
||||
config={
|
||||
"replyStyle": "professional",
|
||||
"creativity": 50,
|
||||
"rigor": 80,
|
||||
"humor": 20,
|
||||
"responseLength": "medium",
|
||||
"systemPrompt": "不要编造政策。",
|
||||
},
|
||||
)
|
||||
self.qa = SimpleNamespace(question="公司地址?", answer="标准地址", enabled=True)
|
||||
self.disabled_qa = SimpleNamespace(question="公司地址?", answer="错误答案", enabled=False)
|
||||
|
||||
def test_enabled_qa_wins_without_calling_model(self):
|
||||
fake_model = Mock()
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
self.avatar,
|
||||
" 公司地址? ",
|
||||
[],
|
||||
qa_pairs=[self.disabled_qa, self.qa],
|
||||
search_fn=lambda *_args, **_kwargs: [],
|
||||
model_client=fake_model,
|
||||
)
|
||||
self.assertEqual(result["source"], "qa")
|
||||
self.assertEqual(result["answer"], "标准地址")
|
||||
fake_model.assert_not_called()
|
||||
|
||||
def test_knowledge_context_is_sent_to_qwen_after_qa_miss(self):
|
||||
fake_model = Mock(return_value="根据知识库内容回答")
|
||||
knowledge_hit = {
|
||||
"filename": "退款.md",
|
||||
"snippet": "知识库内容:七日内可申请退款。",
|
||||
"score": 0.92,
|
||||
}
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
self.avatar,
|
||||
"退款规则",
|
||||
[],
|
||||
qa_pairs=[],
|
||||
search_fn=lambda *_args, **_kwargs: [knowledge_hit],
|
||||
model_client=fake_model,
|
||||
)
|
||||
self.assertEqual(result["source"], "knowledge")
|
||||
self.assertIn("知识库内容", fake_model.call_args.kwargs["messages"][0]["content"])
|
||||
|
||||
def test_prompt_contains_personality_configuration(self):
|
||||
messages = _build_prompt(self.avatar, [], "你好", [])
|
||||
self.assertIn("严谨度", messages[0]["content"])
|
||||
self.assertIn("不要编造政策", messages[0]["content"])
|
||||
|
||||
def test_chat_rejects_avatar_owned_by_another_user(self):
|
||||
class Query:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self.value
|
||||
|
||||
self_avatar = self.avatar
|
||||
|
||||
class DB:
|
||||
avatar = self_avatar
|
||||
|
||||
def query(self, model):
|
||||
return Query(
|
||||
self.avatar if model is Avatar else SimpleNamespace(huihui_user_id="huihui-user-2")
|
||||
)
|
||||
|
||||
db = DB()
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
_require_owned_avatar(db, self.avatar.id, "Bearer other-token")
|
||||
self.assertEqual(caught.exception.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import embeddings
|
||||
|
||||
|
||||
class TextExtractionTests(unittest.TestCase):
|
||||
def write_text(self, suffix, content):
|
||||
handle = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
handle.close()
|
||||
self.addCleanup(lambda: os.path.exists(handle.name) and os.unlink(handle.name))
|
||||
with open(handle.name, "w", encoding="utf-8") as stream:
|
||||
stream.write(content)
|
||||
return handle.name
|
||||
|
||||
def test_extracts_utf8_markdown(self):
|
||||
path = self.write_text(".md", "# 退款规则\n\n七日内可申请退款。")
|
||||
self.assertEqual(embeddings.extract_text(path, ".md"), "# 退款规则\n\n七日内可申请退款。")
|
||||
|
||||
def test_extracts_utf8_text(self):
|
||||
path = self.write_text(".txt", "客服热线:400-123-4567")
|
||||
self.assertEqual(embeddings.extract_text(path, ".txt"), "客服热线:400-123-4567")
|
||||
|
||||
def test_rejects_unsupported_extension(self):
|
||||
path = self.write_text(".csv", "not supported")
|
||||
with self.assertRaises(ValueError):
|
||||
embeddings.extract_text(path, ".csv")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for PUT /api/avatar/{avatar_id}/authorizations/takeover endpoint."""
|
||||
from fastapi.testclient import TestClient
|
||||
from main import app
|
||||
from database import SessionLocal, Base, engine
|
||||
from models import Authorization, Avatar
|
||||
|
||||
|
||||
def setup_test_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
avatar = Avatar(name="test", status="active", config={})
|
||||
db.add(avatar)
|
||||
db.commit()
|
||||
db.refresh(avatar)
|
||||
auth = Authorization(avatar_id=avatar.id, target_id="user1", target_name="测试用户")
|
||||
db.add(auth)
|
||||
db.commit()
|
||||
db.refresh(auth)
|
||||
return db, auth.id
|
||||
|
||||
|
||||
def test_update_takeover_config():
|
||||
db, auth_id = setup_test_db()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.put(
|
||||
f"/api/avatar/test_avatar_id/authorizations/takeover",
|
||||
json={
|
||||
"authorization_id": auth_id,
|
||||
"takeover_enabled": True,
|
||||
"takeover_mode": "delayed",
|
||||
"takeover_delay_seconds": 60,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 200
|
||||
assert data["data"]["takeoverEnabled"] is True
|
||||
assert data["data"]["takeoverMode"] == "delayed"
|
||||
assert data["data"]["takeoverDelaySeconds"] == 60
|
||||
# 验证数据库已更新
|
||||
auth = db.query(Authorization).filter(Authorization.id == auth_id).first()
|
||||
assert auth.takeover_enabled is True
|
||||
assert auth.takeover_mode == "delayed"
|
||||
assert auth.takeover_delay_seconds == 60
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_update_takeover_invalid_mode():
|
||||
db, auth_id = setup_test_db()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.put(
|
||||
f"/api/avatar/test/authorizations/takeover",
|
||||
json={
|
||||
"authorization_id": auth_id,
|
||||
"takeover_mode": "invalid_mode",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 400
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_update_takeover_invalid_delay():
|
||||
db, auth_id = setup_test_db()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.put(
|
||||
f"/api/avatar/test/authorizations/takeover",
|
||||
json={
|
||||
"authorization_id": auth_id,
|
||||
"takeover_delay_seconds": 2,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 400
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_update_takeover_missing_auth_id():
|
||||
client = TestClient(app)
|
||||
response = client.put(
|
||||
f"/api/avatar/test/authorizations/takeover",
|
||||
json={"takeover_enabled": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 400
|
||||
|
||||
|
||||
def test_update_takeover_not_found():
|
||||
client = TestClient(app)
|
||||
response = client.put(
|
||||
f"/api/avatar/test/authorizations/takeover",
|
||||
json={"authorization_id": "nonexistent"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 404
|
||||
@@ -0,0 +1,30 @@
|
||||
from database import SessionLocal
|
||||
from models import Authorization
|
||||
|
||||
|
||||
def test_authorization_takeover_fields():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
auth = db.query(Authorization).first()
|
||||
assert auth is not None
|
||||
# Check new fields exist and have default values
|
||||
assert hasattr(auth, 'takeover_enabled')
|
||||
assert hasattr(auth, 'takeover_mode')
|
||||
assert hasattr(auth, 'takeover_delay_seconds')
|
||||
assert auth.takeover_enabled == False
|
||||
assert auth.takeover_mode == 'immediate'
|
||||
assert auth.takeover_delay_seconds == 30
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_authorization_to_dict_includes_takeover():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
auth = db.query(Authorization).first()
|
||||
d = auth.to_dict()
|
||||
assert 'takeoverEnabled' in d
|
||||
assert 'takeoverMode' in d
|
||||
assert 'takeoverDelaySeconds' in d
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Tests for the scheduled takeover message polling."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
|
||||
def test_app_has_startup_event():
|
||||
"""Verify the app has a startup event configured."""
|
||||
from main import app
|
||||
startup_handlers = [handler for handler in app.router.on_startup]
|
||||
assert len(startup_handlers) > 0
|
||||
|
||||
|
||||
@patch("services.takeover_service.TakeoverService")
|
||||
@patch("services.boxim_client.BoxIMClient")
|
||||
@patch("main.redis_lib.from_url")
|
||||
@patch("main.BackgroundScheduler")
|
||||
def test_scheduler_initialized_with_redis(mock_scheduler_class, mock_redis_from_url, mock_boxim_cls, mock_takeover_cls):
|
||||
"""Verify scheduler is initialized when Redis is available."""
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.ping.return_value = None
|
||||
mock_redis_from_url.return_value = mock_redis
|
||||
|
||||
mock_boxim = MagicMock()
|
||||
mock_boxim_cls.return_value = mock_boxim
|
||||
|
||||
mock_takeover = MagicMock()
|
||||
mock_takeover_cls.return_value = mock_takeover
|
||||
|
||||
with patch("main.init_db"), patch("main.seed"), patch.dict("os.environ", {"REDIS_URL": "redis://localhost:6379"}):
|
||||
from main import on_startup
|
||||
on_startup()
|
||||
|
||||
mock_scheduler_class.return_value.add_job.assert_called_once()
|
||||
call_kwargs = mock_scheduler_class.return_value.add_job.call_args[1]
|
||||
assert call_kwargs["id"] == "takeover_message_poll"
|
||||
|
||||
|
||||
@patch("services.takeover_service.TakeoverService")
|
||||
@patch("services.boxim_client.BoxIMClient")
|
||||
@patch("main.BackgroundScheduler")
|
||||
def test_scheduler_starts_without_redis(mock_scheduler_class, mock_boxim_cls, mock_takeover_cls):
|
||||
"""App should start even when REDIS_URL is not set."""
|
||||
mock_boxim = MagicMock()
|
||||
mock_boxim_cls.return_value = mock_boxim
|
||||
|
||||
mock_takeover = MagicMock()
|
||||
mock_takeover_cls.return_value = mock_takeover
|
||||
|
||||
with patch("main.init_db"), patch("main.seed"), patch.dict("os.environ", {"REDIS_URL": ""}, clear=False):
|
||||
from main import on_startup
|
||||
on_startup()
|
||||
|
||||
mock_scheduler_class.return_value.add_job.assert_called_once()
|
||||
|
||||
|
||||
@patch("services.takeover_service.TakeoverService")
|
||||
@patch("services.boxim_client.BoxIMClient")
|
||||
@patch("main.redis_lib.from_url")
|
||||
@patch("main.BackgroundScheduler")
|
||||
def test_scheduler_starts_when_redis_fails(mock_scheduler_class, mock_redis_from_url, mock_boxim_cls, mock_takeover_cls):
|
||||
"""App should start even when Redis ping fails."""
|
||||
mock_redis_from_url.side_effect = ConnectionError("Connection refused")
|
||||
|
||||
mock_boxim = MagicMock()
|
||||
mock_boxim_cls.return_value = mock_boxim
|
||||
|
||||
mock_takeover = MagicMock()
|
||||
mock_takeover_cls.return_value = mock_takeover
|
||||
|
||||
with patch("main.init_db"), patch("main.seed"), patch.dict("os.environ", {"REDIS_URL": "redis://badhost:6379"}):
|
||||
from main import on_startup
|
||||
on_startup()
|
||||
|
||||
mock_scheduler_class.return_value.add_job.assert_called_once()
|
||||
|
||||
|
||||
@patch("main.BackgroundScheduler")
|
||||
def test_scheduler_fails_gracefully(mock_scheduler_class):
|
||||
"""If scheduler init raises, the app should still start (exception caught)."""
|
||||
mock_scheduler_class.side_effect = RuntimeError("Scheduler crash")
|
||||
|
||||
with patch("main.init_db"), patch("main.seed"):
|
||||
from main import on_startup
|
||||
on_startup()
|
||||
|
||||
# No exception should propagate
|
||||
|
||||
|
||||
# --- poll_and_process_messages ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_boxim():
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_and_process_messages_calls_fetch_and_process(mock_db, mock_boxim):
|
||||
"""poll_and_process_messages should fetch messages and process each."""
|
||||
from services.takeover_service import TakeoverService
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.fetch_unread_messages = AsyncMock(return_value=[
|
||||
{"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"},
|
||||
{"owner_huihui_id": "owner_2", "from_accid": "user_2", "content": "hello"},
|
||||
])
|
||||
service.process_message = AsyncMock()
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
service.fetch_unread_messages.assert_awaited_once()
|
||||
assert service.process_message.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_and_process_messages_handles_errors(mock_db, mock_boxim):
|
||||
"""poll_and_process_messages should not crash on fetch failure."""
|
||||
from services.takeover_service import TakeoverService
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.fetch_unread_messages = AsyncMock(side_effect=ConnectionError("Box IM down"))
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
# No exception should propagate
|
||||
|
||||
|
||||
# --- process_message ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_auth():
|
||||
auth = MagicMock()
|
||||
auth.takeover_enabled = True
|
||||
auth.takeover_mode = "immediate"
|
||||
auth.takeover_delay_seconds = 30
|
||||
auth.avatar_id = "avatar_123"
|
||||
auth.target_id = "target_user_123"
|
||||
return auth
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_immediate_mode(mock_db, mock_boxim, mock_auth):
|
||||
"""When takeover_mode is 'immediate', execute_takeover should be called."""
|
||||
from services.takeover_service import TakeoverService
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.check_takeover_enabled = MagicMock(return_value=mock_auth)
|
||||
service.execute_takeover = AsyncMock(return_value=True)
|
||||
service.enqueue_delayed_message = MagicMock()
|
||||
|
||||
message = {"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"}
|
||||
await service.process_message(message)
|
||||
|
||||
service.execute_takeover.assert_awaited_once_with(mock_auth, message)
|
||||
service.enqueue_delayed_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_delayed_mode(mock_db, mock_boxim, mock_auth):
|
||||
"""When takeover_mode is not 'immediate', message should be enqueued."""
|
||||
from services.takeover_service import TakeoverService
|
||||
|
||||
mock_auth.takeover_mode = "delayed"
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.check_takeover_enabled = MagicMock(return_value=mock_auth)
|
||||
service.execute_takeover = AsyncMock()
|
||||
service.enqueue_delayed_message = MagicMock()
|
||||
|
||||
message = {"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"}
|
||||
await service.process_message(message)
|
||||
|
||||
service.enqueue_delayed_message.assert_called_once_with(mock_auth, message)
|
||||
service.execute_takeover.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_no_takeover(mock_db, mock_boxim):
|
||||
"""When takeover is not enabled, nothing should happen."""
|
||||
from services.takeover_service import TakeoverService
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.check_takeover_enabled = MagicMock(return_value=None)
|
||||
service.execute_takeover = AsyncMock()
|
||||
service.enqueue_delayed_message = MagicMock()
|
||||
|
||||
message = {"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"}
|
||||
await service.process_message(message)
|
||||
|
||||
service.execute_takeover.assert_not_awaited()
|
||||
service.enqueue_delayed_message.assert_not_called()
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Tests for the TakeoverService — message listening, decision, reply execution."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from services.takeover_service import TakeoverService
|
||||
from models import Authorization, Avatar
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_boxim():
|
||||
client = AsyncMock()
|
||||
client.get_credentials.return_value = {"accid": "owner_acc", "token": "tok"}
|
||||
client.send_p2p_message.return_value = True
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_auth():
|
||||
auth = MagicMock(spec=Authorization)
|
||||
auth.takeover_enabled = True
|
||||
auth.takeover_mode = "immediate"
|
||||
auth.takeover_delay_seconds = 30
|
||||
auth.avatar_id = "avatar_123"
|
||||
auth.target_id = "target_user_123"
|
||||
return auth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_avatar():
|
||||
avatar = MagicMock(spec=Avatar)
|
||||
avatar.id = "avatar_123"
|
||||
avatar.owner_id = "owner_huihui_123"
|
||||
return avatar
|
||||
|
||||
|
||||
# --- check_takeover_enabled ---
|
||||
|
||||
|
||||
def test_check_takeover_enabled_returns_auth_when_enabled(mock_db, mock_auth, mock_boxim, mock_avatar):
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
|
||||
auth_filter = MagicMock()
|
||||
auth_filter.filter.return_value = auth_filter
|
||||
auth_filter.first.return_value = mock_auth
|
||||
|
||||
def query_side_effect(model):
|
||||
if model == Avatar:
|
||||
return avatar_query
|
||||
return auth_filter
|
||||
|
||||
mock_db.query.side_effect = query_side_effect
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = service.check_takeover_enabled("owner_huihui_123", "target_user_123")
|
||||
assert result == mock_auth
|
||||
|
||||
|
||||
def test_check_takeover_enabled_returns_none_when_no_avatar(mock_db, mock_boxim):
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = None
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = service.check_takeover_enabled("owner_123", "target_123")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_check_takeover_enabled_returns_none_when_disabled(mock_db, mock_boxim, mock_avatar):
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
|
||||
disabled_auth = MagicMock(spec=Authorization)
|
||||
disabled_auth.takeover_enabled = False
|
||||
auth_filter = MagicMock()
|
||||
auth_filter.filter.return_value = auth_filter
|
||||
auth_filter.first.return_value = disabled_auth
|
||||
|
||||
def query_side_effect(model):
|
||||
if model == Avatar:
|
||||
return avatar_query
|
||||
return auth_filter
|
||||
|
||||
mock_db.query.side_effect = query_side_effect
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = service.check_takeover_enabled("owner_123", "target_123")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_check_takeover_enabled_filters_by_owner_and_target(mock_db, mock_boxim, mock_avatar, mock_auth):
|
||||
"""Verify that queries use the correct filter arguments."""
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
|
||||
auth_filter = MagicMock()
|
||||
auth_filter.filter.return_value = auth_filter
|
||||
auth_filter.first.return_value = mock_auth
|
||||
|
||||
call_order = []
|
||||
|
||||
def query_side_effect(model):
|
||||
if model == Avatar:
|
||||
call_order.append("Avatar")
|
||||
return avatar_query
|
||||
call_order.append("Authorization")
|
||||
return auth_filter
|
||||
|
||||
mock_db.query.side_effect = query_side_effect
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.check_takeover_enabled("owner_huihui_123", "target_user_123")
|
||||
|
||||
assert "Avatar" in call_order
|
||||
assert "Authorization" in call_order
|
||||
|
||||
|
||||
# --- generate_reply ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_reply_returns_answer(mock_boxim):
|
||||
mock_db = MagicMock()
|
||||
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"code": 200, "data": {"answer": "Hello back"}}
|
||||
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = await service.generate_reply("avatar_123", "Hello")
|
||||
assert result == "Hello back"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_reply_handles_empty_answer(mock_boxim):
|
||||
"""generate_reply should return empty string when answer is missing."""
|
||||
mock_db = MagicMock()
|
||||
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"code": 200, "data": {}}
|
||||
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = await service.generate_reply("avatar_123", "Hello")
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_reply_handles_error_code(mock_boxim):
|
||||
"""generate_reply should return empty string when API returns error code."""
|
||||
mock_db = MagicMock()
|
||||
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"code": 500, "message": "Internal error"}
|
||||
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = await service.generate_reply("avatar_123", "Hello")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# --- execute_takeover ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_takeover_success(mock_db, mock_boxim, mock_auth, mock_avatar):
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
|
||||
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"code": 200, "data": {"answer": "Hello back"}}
|
||||
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
message = {"from_accid": "user_acc", "content": "Hello"}
|
||||
|
||||
result = await service.execute_takeover(mock_auth, message)
|
||||
|
||||
assert result is True
|
||||
mock_boxim.get_credentials.assert_called_once_with("owner_huihui_123")
|
||||
mock_boxim.send_p2p_message.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_takeover_fails_when_avatar_not_found(mock_db, mock_boxim, mock_auth):
|
||||
"""execute_takeover should return False when Avatar is not found."""
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = None
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
message = {"from_accid": "user_acc", "content": "Hello"}
|
||||
|
||||
result = await service.execute_takeover(mock_auth, message)
|
||||
|
||||
assert result is False
|
||||
mock_boxim.get_credentials.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_takeover_fails_when_no_credentials(mock_db, mock_boxim, mock_auth, mock_avatar):
|
||||
"""execute_takeover should return False when boxim.get_credentials returns None."""
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
|
||||
mock_boxim.get_credentials.return_value = None
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
message = {"from_accid": "user_acc", "content": "Hello"}
|
||||
|
||||
result = await service.execute_takeover(mock_auth, message)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# --- enqueue_delayed_message ---
|
||||
|
||||
|
||||
def test_enqueue_delayed_message_with_redis(mock_db, mock_boxim, mock_auth, mock_avatar):
|
||||
mock_redis = MagicMock()
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim, mock_redis)
|
||||
message = {"msg_id": "msg_1", "from_accid": "user_acc", "content": "Hello"}
|
||||
|
||||
service.enqueue_delayed_message(mock_auth, message)
|
||||
|
||||
mock_redis.setex.assert_called_once()
|
||||
call_args = mock_redis.setex.call_args
|
||||
value = call_args[0][1]
|
||||
import json
|
||||
payload = json.loads(call_args[0][2])
|
||||
assert payload["owner_huihui_id"] == "owner_huihui_123"
|
||||
|
||||
|
||||
def test_enqueue_delayed_message_without_redis_logs_warning(mock_db, mock_boxim, mock_auth, mock_avatar):
|
||||
"""When Redis is not configured, enqueue_delayed_message should log a warning and not crash."""
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
message = {"msg_id": "msg_1", "from_accid": "user_acc", "content": "Hello"}
|
||||
|
||||
service.enqueue_delayed_message(mock_auth, message)
|
||||
|
||||
|
||||
# --- process_delayed_queue ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_delayed_queue_no_redis(mock_db, mock_boxim):
|
||||
"""process_delayed_queue should return immediately without Redis."""
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
await service.process_delayed_queue()
|
||||
mock_db.query.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_delayed_queue_processes_messages(mock_db, mock_boxim, mock_auth, mock_avatar):
|
||||
"""process_delayed_queue should read from Redis, resolve auth, and execute takeover."""
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.keys.return_value = ["takeover:delayed:target_user_123:msg_1"]
|
||||
mock_redis.get.return_value = '{"from_accid": "user_acc", "content": "Hello"}'
|
||||
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
|
||||
auth_filter = MagicMock()
|
||||
auth_filter.filter.return_value = auth_filter
|
||||
auth_filter.first.return_value = mock_auth
|
||||
|
||||
def query_side_effect(model):
|
||||
if model == Avatar:
|
||||
return avatar_query
|
||||
return auth_filter
|
||||
|
||||
mock_db.query.side_effect = query_side_effect
|
||||
|
||||
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"code": 200, "data": {"answer": "Hello back"}}
|
||||
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim, mock_redis)
|
||||
await service.process_delayed_queue()
|
||||
|
||||
mock_boxim.send_p2p_message.assert_called_once()
|
||||
mock_redis.delete.assert_called_once()
|
||||
@@ -0,0 +1,31 @@
|
||||
# 会会数字分身 —— Docker 测试实例(独立端口,不干扰现有 :8088 huihui 部署)
|
||||
services:
|
||||
avatar-backend:
|
||||
build: ./backend
|
||||
image: avatar-test-backend:latest
|
||||
container_name: avatar-test-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
expose:
|
||||
- "8000"
|
||||
ports:
|
||||
- "8011:8000" # 仅用于直接调试 API;前端经内部网络访问,不走 host 端口
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
avatar-frontend:
|
||||
build: .
|
||||
image: avatar-test-frontend:latest
|
||||
container_name: avatar-test-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8099:80" # 浏览器访问 http://<host>:8099
|
||||
depends_on:
|
||||
- avatar-backend
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
networks:
|
||||
avatar-net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>会会数字分身</title>
|
||||
<!-- uniapp web-view 桥接:加载后全局出现 window.uni.webView,H5 才能与原生壳通信 -->
|
||||
<script type="text/javascript" src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
|
||||
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。
|
||||
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
|
||||
<script type="text/javascript">
|
||||
window.__APP_CONFIG__ = { apiBase: '' }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
# 牙齿防护知识手册
|
||||
|
||||
> 本手册用于「会会」数字分身知识库,覆盖日常护牙、牙线使用、饮食护牙、定期检查、常见口腔问题及儿童口腔保健,供分身专业应答引用。
|
||||
|
||||
## 一、日常刷牙
|
||||
|
||||
- **频率与时机**:每天早晚各刷一次,睡前刷牙尤为重要;饭后建议漱口,进食酸性食物后等待约 30 分钟再刷牙,避免即刻磨损被酸软化的牙釉质。
|
||||
- **巴氏刷牙法(Bass)**:刷毛与牙面呈 45° 指向牙龈沟,小幅度水平震颤 10 次左右,再向牙冠方向拂刷;依次覆盖牙齿外侧、内侧与咬合面。
|
||||
- **时长**:每次刷牙至少 2 分钟。
|
||||
- **工具**:选用软毛、小头牙刷;刷毛外翻或每 3 个月更换一次。
|
||||
- **牙膏**:推荐使用含氟牙膏,氟可增强牙釉质抗酸与再矿化能力。
|
||||
|
||||
## 二、牙线 / 牙缝清洁
|
||||
|
||||
- 每天至少使用一次牙线,清洁牙刷难以到达的牙齿邻面。
|
||||
- 取约 45cm 牙线,以 C 形包绕牙面,上下刮擦清除菌斑。
|
||||
- 牙缝较大或牙周炎人群,可配合使用牙间刷(间隙刷)。
|
||||
|
||||
## 三、饮食与护牙
|
||||
|
||||
- 控制游离糖摄入,少喝含糖饮料、少吃黏性甜食——糖是致龋的主要元凶。
|
||||
- 酸性饮料(碳酸饮料、果汁)会侵蚀牙釉质,建议用吸管饮用并尽快漱口,勿立即刷牙。
|
||||
- 多喝水,唾液具有自我清洁与再矿化作用。
|
||||
- 适量补充钙、磷、维生素 D(奶类、豆制品、深绿色蔬菜)。
|
||||
|
||||
## 四、定期检查与洁治
|
||||
|
||||
- 建议每 6 个月进行一次口腔检查与洁牙(洗牙)。
|
||||
- 洗牙清除牙石与菌斑,预防牙龈炎、牙周炎;**洗牙不会让牙缝变大**(牙缝变大常见于牙周炎后牙龈消肿,属病情暴露而非洗牙造成)。
|
||||
- 出现龋齿、牙龈出血、口臭、牙齿敏感应及时就医,越早处理越简单。
|
||||
|
||||
## 五、常见口腔问题
|
||||
|
||||
- **龋齿(蛀牙)**:浅龋可涂氟干预,但已形成龋洞必须充填,无法自愈。
|
||||
- **牙周病**:表现为牙龈红肿、出血、口臭、牙齿松动;基础治疗为洁治 + 龈下刮治,需长期维护。
|
||||
- **牙齿敏感**:遇冷热酸甜刺激痛,可用抗敏感牙膏;若持续加重需就诊排查楔状缺损或牙龈退缩。
|
||||
- **智齿**:阻生或反复发炎的智齿建议拔除。
|
||||
|
||||
## 六、儿童口腔保健
|
||||
|
||||
- 第一颗乳牙萌出(约 6 月龄)即开始清洁,可用纱布或指套牙刷。
|
||||
- **含氟牙膏用量**:3 岁以下用米粒大小,3–6 岁用豌豆大小,均需在家长监督下使用并防止误吞。
|
||||
- **窝沟封闭**:6–7 岁六龄齿萌出后做窝沟封闭,有效预防窝沟龋。
|
||||
- 定期涂氟,每半年一次口腔检查。
|
||||
|
||||
## 七、常见误区澄清
|
||||
|
||||
- 误区「洗牙伤牙」:事实为规范洗牙安全,不损伤牙釉质。
|
||||
- 误区「牙龈出血就不能刷」:事实为出血多因炎症,更应保持清洁并尽早就医。
|
||||
- 误区「乳牙坏了不用管」:事实为乳牙健康直接影响恒牙与颌骨发育。
|
||||
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"question": "每天应该刷牙几次?每次刷多久?",
|
||||
"answer": "建议每天早晚各刷一次,每次至少 2 分钟,并使用含氟牙膏;睡前那次尤为重要。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "正确的刷牙方法是什么?",
|
||||
"answer": "推荐巴氏刷牙法:刷毛与牙面呈 45° 指向牙龈沟,小幅度水平震颤约 10 次后再向牙冠方向拂刷,依次清洁牙齿外侧、内侧和咬合面。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "电动牙刷比手动牙刷更好吗?",
|
||||
"answer": "两者清洁效果相当,关键在于正确刷牙方法;电动牙刷更易保证刷牙时长,对刷牙不到位的人更友好。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "刷牙总出血是怎么回事?",
|
||||
"answer": "多为牙龈炎或牙周炎所致,建议尽快洗牙并就医检查,同时坚持正确刷牙和使用牙线,切勿因出血而停止刷牙。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "牙线必须每天用吗?怎么用?",
|
||||
"answer": "建议每天至少使用一次牙线清洁牙齿邻面。取约 45cm 牙线,以 C 形包绕牙面上下刮擦;牙缝较大者可配合牙间刷。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "蛀牙(龋齿)能自己好吗?",
|
||||
"answer": "浅龋可通过涂氟减缓进展,但已形成龋洞必须补牙,无法自愈;越早处理越简单、损伤越小。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "洗牙会让牙缝变大、伤牙齿吗?",
|
||||
"answer": "规范的洗牙安全且不损伤牙釉质。洗牙清除的是牙石,牙缝变大常见于牙周炎后牙龈消肿,属病情暴露而非洗牙造成。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "牙齿遇冷热酸甜就酸痛,怎么办?",
|
||||
"answer": "属牙齿敏感,可先使用抗敏感牙膏;若持续加重,需就诊排查楔状缺损或牙龈退缩等成因,再对症处理。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "孩子几岁开始用含氟牙膏?用多少?",
|
||||
"answer": "第一颗乳牙萌出后即可在家长帮助下刷牙;3 岁以下用米粒大小含氟牙膏,3–6 岁用豌豆大小,均需监督防误吞。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "儿童六龄齿需要做窝沟封闭吗?",
|
||||
"answer": "建议 6–7 岁六龄齿萌出后做窝沟封闭,并定期涂氟、每半年口腔检查,可有效预防窝沟龋,保护恒牙。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "喝碳酸饮料会伤牙吗?怎么喝更好?",
|
||||
"answer": "碳酸饮料酸性强,会侵蚀牙釉质。建议用吸管减少接触、喝完尽快清水漱口,不要立即刷牙,并控制频次。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "多久做一次口腔检查和洗牙?",
|
||||
"answer": "建议每 6 个月进行一次口腔检查与洁牙,以便早期发现龋齿、牙周问题并及时干预。",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# 完整主配置:覆盖 nginx:alpine 默认 /etc/nginx/nginx.conf
|
||||
# 新版 nginx 在受限容器内写 /run/nginx.pid 会报 Operation not permitted 并致命退出,
|
||||
# 这里把 pid 显式改到可写的 /tmp(main 上下文唯一一处),避免前端容器反复重启。
|
||||
pid /dev/null;
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA 兜底(hash 路由下深链接也可正常加载)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 后端 API:保留 /api 前缀转发到 avatar-backend:8000
|
||||
location /api/ {
|
||||
proxy_pass http://avatar-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1796
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "digital-avatar-app",
|
||||
"version": "1.0.0",
|
||||
"description": "会会数字分身 Web App",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0",
|
||||
"pinia": "^2.1.0",
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.0"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user