feat: AI虚拟用户新闻互动系统 v1.3.0 初始提交
- 虚拟用户管理(昵称/头像/性别/简介/邮箱同步到目标平台) - AI互动调度(点赞/收藏/评论/转发) - 日志时间改为北京时间 - 评论达上限后继续执行点赞收藏转发 - 一键登出全部功能 - 浅色主题UI
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.env
|
||||||
|
backend/logs/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
294
README.md
Normal file
294
README.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# AI虚拟用户新闻互动系统
|
||||||
|
|
||||||
|
> 基于AI驱动的虚拟用户新闻互动自动化平台,支持批量虚拟用户管理、AI人格生成、真实登录新闻平台、自动随机互动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速部署(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
|
||||||
|
|
||||||
|
# 4. 查看启动日志
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第三步:访问系统
|
||||||
|
|
||||||
|
| 服务 | 地址 |
|
||||||
|
|------|------|
|
||||||
|
| 前端控制台 | http://服务器IP:9000 |
|
||||||
|
| 后端API文档 | http://服务器IP:8000/api/docs |
|
||||||
|
| MySQL | 服务器IP:3306 |
|
||||||
|
| Redis | 服务器IP:6379 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 初始配置
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ 前端更新(重要:必须用此方式)
|
||||||
|
|
||||||
|
> `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个月月度消耗柱状图
|
||||||
|
- 系统运行状态监控
|
||||||
|
|
||||||
|
### 虚拟用户管理
|
||||||
|
- 新增/编辑/删除用户,账号密码AES加密存储
|
||||||
|
- Excel批量导入(含格式校验、去重、错误详情)
|
||||||
|
- Excel批量导出(不含密码密文)
|
||||||
|
- AI人格生成:性格/语言风格/兴趣/互动倾向/字数偏好
|
||||||
|
- 编辑用户资料(昵称/真实姓名/性别/头像/简介/邮箱),支持同步到目标平台
|
||||||
|
- 头像上传:上传图片到平台 filecenter,自动更新用户头像
|
||||||
|
- 单个/批量启用、禁用、登出操作
|
||||||
|
- 手动触发登录/登出
|
||||||
|
|
||||||
|
### AI互动模块
|
||||||
|
- 真实调用新闻平台登录接口获取会话Token
|
||||||
|
- 会话自动校验(10分钟/次),失效自动重登
|
||||||
|
- 随机翻页获取文章,按用户兴趣偏好筛选,自动过滤无效新闻
|
||||||
|
- AI生成贴合人格的评论/回复内容,内容完整不截断,自动过滤敏感词
|
||||||
|
- 按概率随机触发:评论/回复/点赞/收藏/转发
|
||||||
|
- 每日互动次数限额控制
|
||||||
|
- 互动记录支持手动重试、取消
|
||||||
|
|
||||||
|
### AI模型配置
|
||||||
|
- 支持 OpenAI / 智谱GLM / 文心一言 / 通义千问 / 本地模型
|
||||||
|
- API Key AES加密存储
|
||||||
|
- 模型测试功能(验证可用性 + Token消耗预览)
|
||||||
|
- 多模型管理,设置默认模型
|
||||||
|
|
||||||
|
### 调度设置
|
||||||
|
- 互动时间段配置(北京时间)
|
||||||
|
- 最小互动间隔控制(秒),防止同一用户频繁互动
|
||||||
|
- 各互动类型概率独立配置
|
||||||
|
- 并发用户数上限(0=不限)
|
||||||
|
- 每日Token配额管控
|
||||||
|
- 一键暂停/启动调度器
|
||||||
|
- 立即触发互动(测试用)
|
||||||
|
|
||||||
|
### 日志管理
|
||||||
|
- 登录日志:登录/登出/失败记录
|
||||||
|
- 日志文件:应用日志/错误日志实时查看
|
||||||
|
- 日志下载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
**Q: 容器启动失败,提示数据库连接失败?**
|
||||||
|
A: MySQL 启动需要时间,后端依赖 healthcheck。等待 30-60 秒后重试:`docker compose restart ai-virtual-backend`
|
||||||
|
|
||||||
|
**Q: 用户登录始终失败?**
|
||||||
|
A: 1) 检查新闻平台接口地址是否正确;2) 检查账号密码是否正确;3) 查看后端日志定位具体错误
|
||||||
|
|
||||||
|
**Q: AI人格生成失败?**
|
||||||
|
A: 未配置AI模型时系统会随机生成人格作为兜底,这是正常行为。配置有效的AI模型后可重新生成。
|
||||||
|
|
||||||
|
**Q: 调度器不执行互动?**
|
||||||
|
A: 检查:1) 调度器是否启用;2) 是否在设定的互动时间段内(北京时间);3) 是否有已登录状态的用户;4) Token是否已达每日上限;5) 用户最近互动时间是否超过最小间隔
|
||||||
|
|
||||||
|
**Q: 前端修改后没有生效?**
|
||||||
|
A: 不能用 `docker compose build`,必须用上方「前端更新」中的 node 镜像 build 方式。
|
||||||
|
|
||||||
|
**Q: 互动报"服务器繁忙"?**
|
||||||
|
A: 通常是 orgId 为空导致。系统已自动从广场文章数据获取 orgId,如仍报错请检查文章是否有效。
|
||||||
|
|
||||||
|
**Q: 评论报敏感词?**
|
||||||
|
A: AI 提示词已包含安全规则,偶发属正常,系统不重试敏感词失败。
|
||||||
|
|
||||||
|
**Q: 后端 502 错误?**
|
||||||
|
A: 查看日志定位原因:`docker compose logs --tail=20 ai-virtual-backend | grep -E "Error|Exception"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 技术支持
|
||||||
|
|
||||||
|
- 后端API文档:`http://服务器IP:8000/api/docs`
|
||||||
|
- 接口健康检查:`http://服务器IP:8000/health`
|
||||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 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 .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/logs /app/config
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
12
backend/app/api/__init__.py
Normal file
12
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""API路由汇总"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from app.api.endpoints import users, interactions, ai_models, dashboard, system, logs
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
router.include_router(users.router, prefix="/users", tags=["虚拟用户管理"])
|
||||||
|
router.include_router(interactions.router, prefix="/interactions", tags=["互动记录"])
|
||||||
|
router.include_router(ai_models.router, prefix="/ai-models", tags=["AI模型配置"])
|
||||||
|
router.include_router(dashboard.router, prefix="/dashboard", tags=["数据看板"])
|
||||||
|
router.include_router(system.router, prefix="/system", tags=["系统设置"])
|
||||||
|
router.include_router(logs.router, prefix="/logs", tags=["日志管理"])
|
||||||
0
backend/app/api/endpoints/__init__.py
Normal file
0
backend/app/api/endpoints/__init__.py
Normal file
87
backend/app/api/endpoints/ai_models.py
Normal file
87
backend/app/api/endpoints/ai_models.py
Normal file
@@ -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(),
|
||||||
|
}
|
||||||
25
backend/app/api/endpoints/dashboard.py
Normal file
25
backend/app/api/endpoints/dashboard.py
Normal file
@@ -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)
|
||||||
170
backend/app/api/endpoints/interactions.py
Normal file
170
backend/app/api/endpoints/interactions.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""互动记录接口"""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import io, pandas as pd
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.schemas import ApiResponse
|
||||||
|
from app.services.stats_service import stats_service
|
||||||
|
from app.models import InteractionRecord
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_interactions(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
interact_type: Optional[str] = None,
|
||||||
|
status: Optional[int] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
keyword: Optional[str] = None,
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
result = await stats_service.get_interaction_records(
|
||||||
|
db, page, page_size, user_id, interact_type, status, start_date, end_date, keyword
|
||||||
|
)
|
||||||
|
return ApiResponse(data=result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{record_id}/retry")
|
||||||
|
async def retry_interaction(record_id: int, db=Depends(get_db)):
|
||||||
|
"""手动重试失败任务"""
|
||||||
|
result = await db.execute(select(InteractionRecord).where(InteractionRecord.id == record_id))
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
|
if record.status != 2:
|
||||||
|
raise HTTPException(status_code=400, detail="只能重试失败的任务")
|
||||||
|
if record.retry_count >= 3:
|
||||||
|
raise HTTPException(status_code=400, detail="已超过最大重试次数(3次)")
|
||||||
|
|
||||||
|
from app.services.news_service import news_service
|
||||||
|
from app.services.ai_service import ai_service
|
||||||
|
from app.models import VirtualUser, UserPersonality
|
||||||
|
|
||||||
|
user_result = await db.execute(select(VirtualUser).where(VirtualUser.id == record.user_id))
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
if not user or user.status != 2:
|
||||||
|
raise HTTPException(status_code=400, detail="用户未登录,无法重试")
|
||||||
|
|
||||||
|
success, err = False, "未知类型"
|
||||||
|
if record.interact_type == "comment" and record.content:
|
||||||
|
success, err = await news_service.post_comment(db, user, record.article_id, record.article_title or "", record.content)
|
||||||
|
elif record.interact_type == "like":
|
||||||
|
success, err = await news_service.like_news(db, user, record.article_id, org_id="", title=record.article_title or "")
|
||||||
|
elif record.interact_type == "collect":
|
||||||
|
success, err = await news_service.collect_news(db, user, record.article_id, title=record.article_title or "")
|
||||||
|
elif record.interact_type == "forward":
|
||||||
|
success, err = await news_service.forward_news(db, user, record.article_id)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||||
|
status=1 if success else 2,
|
||||||
|
error_msg=None if success else err,
|
||||||
|
retry_count=record.retry_count + 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return ApiResponse(message="重试成功" if success else f"重试失败: {err}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
async def export_interactions(
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
interact_type: Optional[str] = None,
|
||||||
|
status: Optional[int] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""导出互动记录"""
|
||||||
|
data = await stats_service.get_interaction_records(
|
||||||
|
db, 1, 10000, user_id, interact_type, status, start_date, end_date
|
||||||
|
)
|
||||||
|
rows = [{
|
||||||
|
"ID": r["id"], "用户昵称": r["user_nickname"], "用户账号": r["user_account"],
|
||||||
|
"文章标题": r["article_title"], "互动类型": r["interact_type_label"],
|
||||||
|
"内容": r["content"] or "", "Token消耗": r["token_consumed"],
|
||||||
|
"状态": r["status_label"], "失败原因": r["error_msg"] or "",
|
||||||
|
"重试次数": r["retry_count"], "执行时间": r["executed_at"],
|
||||||
|
} for r in data["items"]]
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
df.to_excel(buf, index=False, sheet_name="互动记录")
|
||||||
|
buf.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
buf,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=interactions_export.xlsx"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/{record_id}/cancel")
|
||||||
|
async def cancel_interaction(record_id: int, db=Depends(get_db)):
|
||||||
|
"""取消互动(取消点赞/收藏/删除评论),转发不支持取消"""
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from app.models import InteractionRecord, VirtualUser
|
||||||
|
from app.services.news_service import news_service
|
||||||
|
|
||||||
|
# 查找互动记录
|
||||||
|
r = await db.execute(select(InteractionRecord).where(InteractionRecord.id == record_id))
|
||||||
|
record = r.scalar_one_or_none()
|
||||||
|
if not record:
|
||||||
|
return ApiResponse(code=404, message="记录不存在")
|
||||||
|
|
||||||
|
if record.status != 1:
|
||||||
|
return ApiResponse(code=400, message="只能取消成功的互动")
|
||||||
|
|
||||||
|
if record.interact_type == "forward":
|
||||||
|
return ApiResponse(code=400, message="转发互动无法取消")
|
||||||
|
|
||||||
|
if record.interact_type == "read":
|
||||||
|
return ApiResponse(code=400, message="阅读记录无法取消")
|
||||||
|
|
||||||
|
# 查找对应用户
|
||||||
|
ur = await db.execute(select(VirtualUser).where(VirtualUser.id == record.user_id))
|
||||||
|
user = ur.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
return ApiResponse(code=404, message="用户不存在")
|
||||||
|
|
||||||
|
# 执行取消
|
||||||
|
ok = False
|
||||||
|
err = ""
|
||||||
|
if record.interact_type in ("like",):
|
||||||
|
ok, err = await news_service.cancel_like(
|
||||||
|
db, user,
|
||||||
|
news_id=record.article_id or "",
|
||||||
|
org_id=record.session_id or "", # session_id 字段暂存 org_id
|
||||||
|
title=record.article_title or "",
|
||||||
|
)
|
||||||
|
elif record.interact_type == "collect":
|
||||||
|
ok, err = await news_service.cancel_collect(
|
||||||
|
db, user,
|
||||||
|
news_id=record.article_id or "",
|
||||||
|
title=record.article_title or "",
|
||||||
|
)
|
||||||
|
elif record.interact_type in ("comment", "reply"):
|
||||||
|
comment_id = record.platform_record_id or ""
|
||||||
|
if not comment_id:
|
||||||
|
return ApiResponse(code=400, message="缺少评论ID,无法删除")
|
||||||
|
ok, err = await news_service.cancel_comment(
|
||||||
|
db, user,
|
||||||
|
news_id=record.article_id or "",
|
||||||
|
comment_id=comment_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
# 更新状态为手动取消(status=3)
|
||||||
|
await db.execute(
|
||||||
|
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||||
|
status=3, error_msg="手动取消"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return ApiResponse(message="取消成功")
|
||||||
|
else:
|
||||||
|
return ApiResponse(code=500, message=f"取消失败: {err}")
|
||||||
83
backend/app/api/endpoints/logs.py
Normal file
83
backend/app/api/endpoints/logs.py
Normal file
@@ -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.isoformat()
|
||||||
|
} 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")
|
||||||
115
backend/app/api/endpoints/system.py
Normal file
115
backend/app/api/endpoints/system.py
Normal file
@@ -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}")
|
||||||
370
backend/app/api/endpoints/users.py
Normal file
370
backend/app/api/endpoints/users.py
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
"""虚拟用户管理接口"""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import io
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.schemas import ApiResponse, UserCreateRequest, UserUpdateRequest, UserBatchRequest, PersonalityUpdateRequest
|
||||||
|
from app.services.user_service import user_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_users(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
keyword: Optional[str] = Query(default=None),
|
||||||
|
status: Optional[int] = Query(default=None),
|
||||||
|
is_enabled: Optional[int] = Query(default=None),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取虚拟用户列表"""
|
||||||
|
total, items = await user_service.get_users(db, page, page_size, keyword, status, is_enabled)
|
||||||
|
return ApiResponse(data={"total": total, "page": page, "page_size": page_size, "items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_user(req: UserCreateRequest, db=Depends(get_db)):
|
||||||
|
"""创建虚拟用户"""
|
||||||
|
user = await user_service.create_user(db, req)
|
||||||
|
return ApiResponse(data=user, message="用户创建成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}")
|
||||||
|
async def get_user(user_id: int, db=Depends(get_db)):
|
||||||
|
"""获取单个用户详情"""
|
||||||
|
total, items = await user_service.get_users(db, 1, 1)
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models import VirtualUser, UserPersonality
|
||||||
|
from app.services.user_service import user_service as svc
|
||||||
|
result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
p_result = await db.execute(select(UserPersonality).where(UserPersonality.user_id == user_id))
|
||||||
|
personality = p_result.scalar_one_or_none()
|
||||||
|
return ApiResponse(data=svc._format_user(user, personality))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}")
|
||||||
|
async def update_user(user_id: int, req: UserUpdateRequest, db=Depends(get_db)):
|
||||||
|
"""更新用户信息(sync_to_platform=true 时同步到目标平台)"""
|
||||||
|
result = await user_service.update_user(db, user_id, req)
|
||||||
|
|
||||||
|
if req.sync_to_platform:
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models import VirtualUser as _VU
|
||||||
|
from app.services.news_service import news_service
|
||||||
|
ur = await db.execute(select(_VU).where(_VU.id == user_id))
|
||||||
|
user = ur.scalar_one_or_none()
|
||||||
|
if user and user.status == 2:
|
||||||
|
ok, err = await news_service.update_user_profile(
|
||||||
|
db, user,
|
||||||
|
nick_name=req.nickname,
|
||||||
|
real_name=req.real_name,
|
||||||
|
sex=req.sex,
|
||||||
|
description=req.description,
|
||||||
|
email=req.email,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return ApiResponse(data=result,
|
||||||
|
message=f"本地已保存,同步到平台失败: {err}", code=206)
|
||||||
|
|
||||||
|
return ApiResponse(data=result, message="更新成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}")
|
||||||
|
async def delete_user(user_id: int, db=Depends(get_db)):
|
||||||
|
"""删除用户"""
|
||||||
|
await user_service.delete_user(db, user_id)
|
||||||
|
return ApiResponse(message="删除成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch/action")
|
||||||
|
async def batch_action(req: UserBatchRequest, db=Depends(get_db)):
|
||||||
|
"""批量操作用户"""
|
||||||
|
result = await user_service.batch_action(db, req.user_ids, req.action)
|
||||||
|
return ApiResponse(data=result, message="批量操作成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/login")
|
||||||
|
async def manual_login(user_id: int, db=Depends(get_db)):
|
||||||
|
"""手动触发用户登录"""
|
||||||
|
from app.services.news_service import news_service
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models import VirtualUser
|
||||||
|
result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
success = await news_service.login(db, user)
|
||||||
|
if success:
|
||||||
|
return ApiResponse(message="登录成功")
|
||||||
|
raise HTTPException(status_code=400, detail="登录失败,请检查账号密码")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/logout")
|
||||||
|
async def manual_logout(user_id: int, db=Depends(get_db)):
|
||||||
|
"""手动登出"""
|
||||||
|
from app.services.news_service import news_service
|
||||||
|
await news_service.logout(db, user_id)
|
||||||
|
return ApiResponse(message="已登出")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/personality/generate")
|
||||||
|
async def generate_personality(user_id: int, db=Depends(get_db)):
|
||||||
|
"""重新生成AI人格"""
|
||||||
|
personality = await user_service.generate_personality(db, user_id)
|
||||||
|
return ApiResponse(data=personality, message="人格生成成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/personality")
|
||||||
|
async def update_personality(user_id: int, req: PersonalityUpdateRequest, db=Depends(get_db)):
|
||||||
|
"""手动编辑人格属性"""
|
||||||
|
personality = await user_service.update_personality(db, user_id, req)
|
||||||
|
return ApiResponse(data=personality, message="人格更新成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/excel/template")
|
||||||
|
async def download_template():
|
||||||
|
"""下载Excel导入模板"""
|
||||||
|
content = await user_service.get_excel_template()
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(content),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=virtual_users_template.xlsx"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/excel/import")
|
||||||
|
async def import_excel(file: UploadFile = File(...), db=Depends(get_db)):
|
||||||
|
"""Excel批量导入"""
|
||||||
|
if not file.filename.endswith((".xlsx", ".xls")):
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持Excel文件(.xlsx/.xls)")
|
||||||
|
content = await file.read()
|
||||||
|
result = await user_service.import_from_excel(db, content)
|
||||||
|
return ApiResponse(data=result, message=f"导入完成:成功{result['success']}条,失败{result['failed']}条")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/excel/export")
|
||||||
|
async def export_excel(db=Depends(get_db)):
|
||||||
|
"""导出用户数据Excel"""
|
||||||
|
content = await user_service.export_to_excel(db)
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(content),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=virtual_users_export.xlsx"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/deduplicate")
|
||||||
|
async def deduplicate_users(db=Depends(get_db)):
|
||||||
|
"""删除重复用户(保留最早创建的一条)"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
# 找出重复的账号,保留 id 最小的,删除其他的
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
DELETE FROM virtual_users
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT MIN(id) FROM virtual_users GROUP BY account
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
deleted = result.rowcount
|
||||||
|
return ApiResponse(data={"deleted": deleted}, message=f"已清理 {deleted} 条重复数据")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-all")
|
||||||
|
async def clear_all_users(db=Depends(get_db)):
|
||||||
|
"""清空所有用户(慎用)"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.core.redis_client import get_redis
|
||||||
|
await db.execute(text("DELETE FROM user_personalities"))
|
||||||
|
await db.execute(text("DELETE FROM virtual_users"))
|
||||||
|
await db.commit()
|
||||||
|
return ApiResponse(message="已清空所有用户数据")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login-all")
|
||||||
|
async def batch_login_all(db=Depends(get_db)):
|
||||||
|
"""一键登录所有未登录/登录失效的用户"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.services.news_service import news_service
|
||||||
|
from app.models import VirtualUser as _VU
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# 先用当前 session 查出所有待登录用户 ID
|
||||||
|
result_r = await db.execute(
|
||||||
|
select(_VU.id, _VU.account).where(
|
||||||
|
_VU.is_enabled == 1,
|
||||||
|
_VU.status.in_([0, 3])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = result_r.all()
|
||||||
|
if not rows:
|
||||||
|
return ApiResponse(message="没有需要登录的用户", data={"count": 0})
|
||||||
|
|
||||||
|
user_ids = [r[0] for r in rows]
|
||||||
|
total = len(user_ids)
|
||||||
|
success = failed = 0
|
||||||
|
|
||||||
|
# 每个用户独立 session,避免事务污染
|
||||||
|
async def login_one(uid: int):
|
||||||
|
async with AsyncSessionLocal() as s:
|
||||||
|
try:
|
||||||
|
ur = await s.execute(select(_VU).where(_VU.id == uid))
|
||||||
|
u = ur.scalar_one_or_none()
|
||||||
|
if u:
|
||||||
|
return await news_service.login(s, u)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"login_one {uid} 异常: {e}")
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
batch_size = 5
|
||||||
|
for i in range(0, total, batch_size):
|
||||||
|
batch_ids = user_ids[i:i+batch_size]
|
||||||
|
results = await asyncio.gather(*[login_one(uid) for uid in batch_ids], return_exceptions=True)
|
||||||
|
for r in results:
|
||||||
|
if r is True: success += 1
|
||||||
|
else: failed += 1
|
||||||
|
if i + batch_size < total:
|
||||||
|
await asyncio.sleep(1) # 批次间隔避免过于集中
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
message=f"登录完成:成功 {success} 个,失败 {failed} 个",
|
||||||
|
data={"success": success, "failed": failed, "total": total}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync-all-profiles")
|
||||||
|
async def sync_all_profiles(db=Depends(get_db)):
|
||||||
|
"""
|
||||||
|
同步所有已登录用户的平台信息(昵称/真实姓名/性别/头像)到本系统
|
||||||
|
从登录 session 中的 token 调用目标平台接口获取最新用户信息
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from app.models import VirtualUser as _VU
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.core.redis_client import get_session
|
||||||
|
import httpx, asyncio
|
||||||
|
|
||||||
|
# 查出所有已登录用户
|
||||||
|
result_r = await db.execute(select(_VU).where(_VU.status == 2, _VU.is_enabled == 1))
|
||||||
|
users = result_r.scalars().all()
|
||||||
|
if not users:
|
||||||
|
return ApiResponse(message="没有已登录的用户", data={"synced": 0})
|
||||||
|
|
||||||
|
synced = failed = 0
|
||||||
|
|
||||||
|
async def sync_one(uid: int):
|
||||||
|
"""从登录 session 中提取已缓存的用户信息,直接写入数据库,无需调用外部接口"""
|
||||||
|
async with AsyncSessionLocal() as s:
|
||||||
|
try:
|
||||||
|
sess = await get_session(uid)
|
||||||
|
if not sess:
|
||||||
|
return False
|
||||||
|
platform_uid = sess.get("platform_uid", "")
|
||||||
|
# 登录成功时 session 里已存有用户信息
|
||||||
|
vals = {}
|
||||||
|
if platform_uid: vals["platform_uid"] = platform_uid
|
||||||
|
# session 里的字段(登录时写入)
|
||||||
|
if sess.get("nickname"): vals["nickname"] = sess["nickname"]
|
||||||
|
if sess.get("real_name"): vals["real_name"] = sess["real_name"]
|
||||||
|
if sess.get("sex"): vals["sex"] = int(sess["sex"])
|
||||||
|
if sess.get("avatar"): vals["avatar_url"] = sess["avatar"]
|
||||||
|
if vals:
|
||||||
|
await s.execute(update(_VU).where(_VU.id == uid).values(**vals))
|
||||||
|
await s.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"sync_one {uid} 失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[sync_one(u.id) for u in users], return_exceptions=True)
|
||||||
|
for r in results:
|
||||||
|
if r is True: synced += 1
|
||||||
|
else: failed += 1
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
message=f"同步完成:成功 {synced} 个,失败/跳过 {failed} 个",
|
||||||
|
data={"synced": synced, "failed": failed, "total": len(users)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/{user_id}/upload-avatar")
|
||||||
|
async def upload_avatar(
|
||||||
|
user_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
sync_to_platform: bool = Query(default=True),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""上传头像并可选同步到目标平台"""
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from app.models import VirtualUser as _VU
|
||||||
|
from app.services.news_service import news_service
|
||||||
|
|
||||||
|
ur = await db.execute(select(_VU).where(_VU.id == user_id))
|
||||||
|
user = ur.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
return ApiResponse(code=404, message="用户不存在")
|
||||||
|
|
||||||
|
# 读取文件内容
|
||||||
|
file_bytes = await file.read()
|
||||||
|
if len(file_bytes) > 5 * 1024 * 1024:
|
||||||
|
return ApiResponse(code=400, message="头像文件不能超过5MB")
|
||||||
|
|
||||||
|
avatar_url = None
|
||||||
|
|
||||||
|
if sync_to_platform and user.status == 2:
|
||||||
|
# 上传到目标平台
|
||||||
|
ok, result = await news_service.upload_avatar(db, user, file_bytes, file.filename)
|
||||||
|
if ok:
|
||||||
|
avatar_url = result
|
||||||
|
else:
|
||||||
|
return ApiResponse(code=500, message=f"头像上传到平台失败: {result}")
|
||||||
|
else:
|
||||||
|
# 仅本地存储(转 base64 或存储到本地)
|
||||||
|
import base64
|
||||||
|
avatar_url = f"data:{file.content_type};base64,{base64.b64encode(file_bytes).decode()}"
|
||||||
|
|
||||||
|
# 更新数据库
|
||||||
|
await db.execute(update(_VU).where(_VU.id == user_id).values(avatar_url=avatar_url))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# 如果已同步到平台,再调用 update_user_profile 更新头像字段
|
||||||
|
if sync_to_platform and user.status == 2 and avatar_url:
|
||||||
|
await news_service.update_user_profile(db, user, avatar=avatar_url)
|
||||||
|
|
||||||
|
return ApiResponse(data={"avatar_url": avatar_url}, message="头像更新成功")
|
||||||
|
@router.post("/logout-all")
|
||||||
|
async def batch_logout_all(db=Depends(get_db)):
|
||||||
|
"""一键登出所有已登录用户"""
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from app.models import VirtualUser as _VU
|
||||||
|
from app.core.redis_client import delete_session
|
||||||
|
|
||||||
|
result_r = await db.execute(
|
||||||
|
select(_VU.id).where(_VU.status == 2, _VU.is_enabled == 1)
|
||||||
|
)
|
||||||
|
rows = result_r.all()
|
||||||
|
if not rows:
|
||||||
|
return ApiResponse(message="没有已登录的用户", data={"count": 0})
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
await delete_session(row[0])
|
||||||
|
count += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 更新所有用户状态为未登录
|
||||||
|
await db.execute(
|
||||||
|
update(_VU).where(_VU.status == 2).values(status=0)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return ApiResponse(message=f"已登出 {count} 个用户", data={"count": count})
|
||||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# app.core package
|
||||||
46
backend/app/core/config.py
Normal file
46
backend/app/core/config.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""系统配置"""
|
||||||
|
import os
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# 数据库
|
||||||
|
DB_HOST: str = os.getenv("DB_HOST", "localhost")
|
||||||
|
DB_PORT: int = int(os.getenv("DB_PORT", "3306"))
|
||||||
|
DB_USER: str = os.getenv("DB_USER", "aivirtual")
|
||||||
|
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "AiVirtual2024")
|
||||||
|
DB_NAME: str = os.getenv("DB_NAME", "ai_virtual_news")
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
|
||||||
|
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
||||||
|
|
||||||
|
# 安全
|
||||||
|
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-prod")
|
||||||
|
AES_KEY: str = os.getenv("AES_KEY", "your-aes-key-32-chars-change-now!")
|
||||||
|
|
||||||
|
# 新闻平台
|
||||||
|
NEWS_PLATFORM_BASE_URL: str = os.getenv(
|
||||||
|
"NEWS_PLATFORM_BASE_URL", "http://192.168.1.200:63120"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 日志目录
|
||||||
|
LOG_DIR: str = "/app/logs"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def 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"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
72
backend/app/core/database.py
Normal file
72
backend/app/core/database.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""数据库连接管理"""
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
echo=False,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
"""获取数据库会话"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_db(max_retries: int = 30, interval: int = 2):
|
||||||
|
"""等待 MySQL 就绪,最多重试 max_retries 次"""
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(__import__("sqlalchemy").text("SELECT 1"))
|
||||||
|
logger.info(f"✅ 数据库连接成功(第 {attempt} 次尝试)")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == max_retries:
|
||||||
|
logger.error(f"数据库连接失败,已重试 {max_retries} 次: {e}")
|
||||||
|
raise
|
||||||
|
logger.warning(f"数据库未就绪,{interval}s 后重试({attempt}/{max_retries}): {e}")
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
"""初始化数据库 - 等待 MySQL 就绪并注册所有模型"""
|
||||||
|
try:
|
||||||
|
# 等待 MySQL 容器真正就绪
|
||||||
|
await wait_for_db(max_retries=30, interval=2)
|
||||||
|
|
||||||
|
# 导入所有模型类,确保 SQLAlchemy ORM 元数据注册
|
||||||
|
from app.models import (
|
||||||
|
VirtualUser, UserPersonality, InteractionRecord,
|
||||||
|
TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||||
|
)
|
||||||
|
logger.info("✅ 数据库模型注册成功")
|
||||||
|
logger.info("✅ 数据库初始化完成")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"数据库初始化失败: {e}")
|
||||||
|
raise
|
||||||
48
backend/app/core/logger.py
Normal file
48
backend/app/core/logger.py
Normal file
@@ -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"]
|
||||||
81
backend/app/core/redis_client.py
Normal file
81
backend/app/core/redis_client.py
Normal file
@@ -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
|
||||||
65
backend/app/main.py
Normal file
65
backend/app/main.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
AI虚拟用户新闻互动系统 - 后端主入口
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""应用生命周期管理"""
|
||||||
|
logger.info("🚀 AI虚拟用户新闻互动系统启动中...")
|
||||||
|
# 初始化数据库
|
||||||
|
await init_db()
|
||||||
|
# 启动调度器
|
||||||
|
await scheduler_service.start()
|
||||||
|
logger.info("✅ 系统启动完成")
|
||||||
|
yield
|
||||||
|
# 关闭调度器
|
||||||
|
await scheduler_service.stop()
|
||||||
|
logger.info("🛑 系统已关闭")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="AI虚拟用户新闻互动系统",
|
||||||
|
description="基于AI驱动的虚拟用户新闻互动自动化平台",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url="/api/redoc",
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注册路由
|
||||||
|
app.include_router(router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "ok", "service": "ai-virtual-news-backend"}
|
||||||
|
|
||||||
|
|
||||||
|
@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)}"},
|
||||||
|
)
|
||||||
132
backend/app/models/__init__.py
Normal file
132
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualUser(Base):
|
||||||
|
__tablename__ = "virtual_users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
nickname: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
account: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
|
||||||
|
password_enc: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
avatar_url: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
status: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||||
|
activity_level: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||||
|
daily_comment_limit: Mapped[int] = mapped_column(Integer, default=10)
|
||||||
|
daily_like_limit: Mapped[int] = mapped_column(Integer, default=30)
|
||||||
|
today_comment_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
today_like_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
total_interactions: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
session_token: Mapped[str | None] = mapped_column(Text)
|
||||||
|
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
|
last_interact_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
|
real_name: Mapped[str | None] = mapped_column(String(64)) # 真实姓名(从平台同步)
|
||||||
|
sex: Mapped[int] = mapped_column(SmallInteger, default=0) # 性别 0未知 1男 2女
|
||||||
|
platform_uid: Mapped[str | None] = mapped_column(String(64)) # 平台用户ID
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(256))
|
||||||
|
is_enabled: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class UserPersonality(Base):
|
||||||
|
__tablename__ = "user_personalities"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True)
|
||||||
|
character_type: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
language_style: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
interest_tags: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
interact_tendency: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
word_count_min: Mapped[int] = mapped_column(Integer, default=20)
|
||||||
|
word_count_max: Mapped[int] = mapped_column(Integer, default=100)
|
||||||
|
personality_desc: Mapped[str | None] = mapped_column(Text)
|
||||||
|
comment_style_prompt: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionRecord(Base):
|
||||||
|
__tablename__ = "interaction_records"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||||
|
user_nickname: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
user_account: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
article_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
article_title: Mapped[str | None] = mapped_column(String(256))
|
||||||
|
interact_type: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||||
|
content: Mapped[str | None] = mapped_column(Text)
|
||||||
|
platform_record_id: Mapped[str | None] = mapped_column(String(64)) # 平台返回的记录ID(用于取消互动)
|
||||||
|
parent_comment_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
session_id: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
token_consumed: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
status: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||||
|
error_msg: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
retry_count: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||||
|
executed_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class TokenStat(Base):
|
||||||
|
__tablename__ = "token_stats"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
stat_date: Mapped[datetime] = mapped_column(Date, nullable=False, unique=True)
|
||||||
|
model_name: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
prompt_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
completion_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
call_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelConfig(Base):
|
||||||
|
__tablename__ = "ai_model_configs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
model_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
api_base_url: Mapped[str | None] = mapped_column(String(256))
|
||||||
|
api_key_enc: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
model_version: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
temperature: Mapped[float] = mapped_column(Float, default=0.7)
|
||||||
|
max_tokens: Mapped[int] = mapped_column(Integer, default=1000)
|
||||||
|
timeout_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
||||||
|
is_default: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||||
|
is_enabled: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfig(Base):
|
||||||
|
__tablename__ = "system_configs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
config_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
config_value: Mapped[str | None] = mapped_column(Text)
|
||||||
|
config_type: Mapped[str] = mapped_column(String(16), default="string")
|
||||||
|
description: Mapped[str | None] = mapped_column(String(256))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class LoginLog(Base):
|
||||||
|
__tablename__ = "login_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||||
|
user_account: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
action: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
session_id: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
error_msg: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
|
||||||
19
backend/app/models/all_models.py
Normal file
19
backend/app/models/all_models.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
220
backend/app/schemas/__init__.py
Executable file
220
backend/app/schemas/__init__.py
Executable file
@@ -0,0 +1,220 @@
|
|||||||
|
"""Pydantic数据模型 - 请求/响应模式"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List, Any
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 通用响应 =====
|
||||||
|
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
|
||||||
215
backend/app/schemas/__init__.pybuckup
Normal file
215
backend/app/schemas/__init__.pybuckup
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""Pydantic数据模型 - 请求/响应模式"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List, Any
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 通用响应 =====
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
258
backend/app/services/ai_service.py
Normal file
258
backend/app/services/ai_service.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""AI服务 - 人格生成、内容创作"""
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
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大模型服务"""
|
||||||
|
|
||||||
|
# 人格候选池
|
||||||
|
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, 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=300)
|
||||||
|
|
||||||
|
async def generate_reply(
|
||||||
|
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}
|
||||||
|
|
||||||
|
重要规则:回复必须积极正面、文明友善,不含任何敏感违规内容。"""
|
||||||
|
prompt = f"""文章:{article_title}
|
||||||
|
原评论:{parent_comment}
|
||||||
|
|
||||||
|
请对上面的评论写一条友善自然的回复,{word_min}~{word_max}字,直接输出回复内容。"""
|
||||||
|
return await self._call_api(db, prompt, system_prompt, max_tokens=150)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
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"记录Token消耗失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
ai_service = AIService()
|
||||||
729
backend/app/services/news_service.py
Executable file
729
backend/app/services/news_service.py
Executable file
@@ -0,0 +1,729 @@
|
|||||||
|
"""
|
||||||
|
新闻平台对接服务
|
||||||
|
登录: POST {auth}/open/login/token (formData)
|
||||||
|
签名: 完全对应 sign.js 的实现
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from app.models import VirtualUser, SystemConfig, LoginLog
|
||||||
|
from app.utils.crypto import decrypt
|
||||||
|
from app.core.redis_client import set_session, get_session, delete_session
|
||||||
|
from app.core.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class NewsPlatformService:
|
||||||
|
|
||||||
|
# ─── 配置读取 ──────────────────────────────────────────────
|
||||||
|
async def _cfg(self, db: AsyncSession, key: str, default: str = "") -> str:
|
||||||
|
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == key))
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
return row.config_value if row else default
|
||||||
|
|
||||||
|
async def _biz_url(self, db: AsyncSession) -> str:
|
||||||
|
return await self._cfg(db, "news_platform_base_url", "http://192.168.1.200:63120")
|
||||||
|
|
||||||
|
async def _auth_url(self, db: AsyncSession) -> str:
|
||||||
|
return await self._cfg(db, "auth_base_url", "http://192.168.1.200:60040")
|
||||||
|
|
||||||
|
async def _client(self, db: AsyncSession) -> dict:
|
||||||
|
return {
|
||||||
|
"appId": await self._cfg(db, "platform_app_id", ""),
|
||||||
|
"accessId": await self._cfg(db, "platform_access_id", ""),
|
||||||
|
"accessSecret": await self._cfg(db, "platform_access_secret", ""),
|
||||||
|
"clientCode": await self._cfg(db, "platform_client_code", ""),
|
||||||
|
"orgId": await self._cfg(db, "platform_org_id", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── 签名(完全对应 sign.js 逻辑) ─────────────────────────
|
||||||
|
@staticmethod
|
||||||
|
def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str:
|
||||||
|
"""
|
||||||
|
完全对应 sign.js:
|
||||||
|
1. 所有参数 key 排序
|
||||||
|
2. 过滤掉 signature / accessSecret / 空值 / 空数组
|
||||||
|
3. 拼接 key=value& ... accessSecret=secretKey
|
||||||
|
4. MD5/SHA256 大写
|
||||||
|
"""
|
||||||
|
SIGN_KEY = "signature"
|
||||||
|
SECRET_KEY = "accessSecret"
|
||||||
|
|
||||||
|
keys = sorted(params.keys())
|
||||||
|
str_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
|
||||||
|
str_parts.append(f"{k}={v}")
|
||||||
|
|
||||||
|
sign_str = "&".join(str_parts) + f"&{SECRET_KEY}={secret_key}"
|
||||||
|
|
||||||
|
if sign_type.upper() == "SHA256":
|
||||||
|
return hashlib.sha256(sign_str.encode("utf-8")).hexdigest().upper()
|
||||||
|
else:
|
||||||
|
return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_nonce() -> str:
|
||||||
|
import random, math
|
||||||
|
return str(random.random())[2:][: random.randint(8, 12)]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_timestamp() -> str:
|
||||||
|
"""yyyyMMddhhmmss — 注意 sign.js 用小写 hh(12小时制)"""
|
||||||
|
now = datetime.now()
|
||||||
|
# sign.js 用 yyyyMMddhhmmss (12小时制小写hh)
|
||||||
|
return now.strftime("%Y%m%d%I%M%S")
|
||||||
|
|
||||||
|
def _build_form(self, extra: dict, cfg: dict) -> dict:
|
||||||
|
"""构建带签名的 formData"""
|
||||||
|
sign_type = "MD5"
|
||||||
|
sign_version = "1.0"
|
||||||
|
secret_key = cfg.get("accessSecret", "")
|
||||||
|
|
||||||
|
base = {
|
||||||
|
"appId": cfg.get("appId", ""),
|
||||||
|
"accessId": cfg.get("accessId", ""),
|
||||||
|
"timestamp": self._get_timestamp(),
|
||||||
|
"signType": sign_type,
|
||||||
|
"signVersion": sign_version,
|
||||||
|
"accessSecret": secret_key,
|
||||||
|
"nonce": self._get_nonce(),
|
||||||
|
}
|
||||||
|
base.update(extra)
|
||||||
|
|
||||||
|
# 计算签名
|
||||||
|
signature = self._make_sign(base, secret_key, sign_type) if secret_key else ""
|
||||||
|
base["signature"] = signature
|
||||||
|
|
||||||
|
# 移除 accessSecret(不发送到服务器)
|
||||||
|
base.pop("accessSecret", None)
|
||||||
|
return base
|
||||||
|
|
||||||
|
# ─── 登录 ──────────────────────────────────────────────────
|
||||||
|
async def login(self, db: AsyncSession, user: VirtualUser) -> bool:
|
||||||
|
password = decrypt(user.password_enc)
|
||||||
|
if not password:
|
||||||
|
logger.error(f"[登录] {user.account} 密码解密失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
auth = await self._auth_url(db)
|
||||||
|
cfg = await self._client(db)
|
||||||
|
|
||||||
|
await db.execute(update(VirtualUser).where(VirtualUser.id == user.id).values(status=1))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
extra = {
|
||||||
|
"username": user.account,
|
||||||
|
"password": password,
|
||||||
|
"loginType": "password",
|
||||||
|
"grantType": "password",
|
||||||
|
"isRegister": "false",
|
||||||
|
}
|
||||||
|
if cfg.get("clientCode"):
|
||||||
|
extra["clientCode"] = cfg["clientCode"]
|
||||||
|
|
||||||
|
form = self._build_form(extra, cfg)
|
||||||
|
|
||||||
|
exc = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=30,
|
||||||
|
follow_redirects=True, # 自动跟随 HTTP 重定向
|
||||||
|
) as c:
|
||||||
|
# 登录接口路径:需要加 /usercenter 前缀(通过网关路由)
|
||||||
|
# auth_base_url 配置为完整前缀,如 https://fat-open.99hui.com/api/usercenter
|
||||||
|
login_url = f"{auth}/open/login/token"
|
||||||
|
resp = await c.post(login_url, data=form)
|
||||||
|
|
||||||
|
# 详细记录原始响应,便于排查
|
||||||
|
logger.info(
|
||||||
|
f"[登录] {user.account} 原始响应: "
|
||||||
|
f"status={resp.status_code} "
|
||||||
|
f"content-type={resp.headers.get('content-type','')} "
|
||||||
|
f"body={resp.text[:500]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 防止空响应体崩溃
|
||||||
|
if not resp.text.strip():
|
||||||
|
logger.warning(f"[登录] {user.account} 服务器返回空响应体,接口可能不存在或被重定向")
|
||||||
|
raise ValueError(f"服务器返回空响应 HTTP={resp.status_code}")
|
||||||
|
|
||||||
|
# 尝试解析 JSON
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except Exception as je:
|
||||||
|
logger.warning(f"[登录] {user.account} 响应非JSON: {resp.text[:200]}")
|
||||||
|
raise ValueError(f"响应非JSON: {resp.text[:100]}")
|
||||||
|
|
||||||
|
if resp.status_code == 200 and body.get("code") in [0, 200]:
|
||||||
|
raw = body.get("data")
|
||||||
|
access_token, platform_uid = self._extract_token(raw)
|
||||||
|
|
||||||
|
if access_token:
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
# 登录成功后尝试获取用户组织信息
|
||||||
|
org_id = await self._fetch_org_id(db, access_token, platform_uid, cfg)
|
||||||
|
if org_id and not cfg.get("orgId"):
|
||||||
|
# 如果系统没有配置 orgId,则自动保存用户所属组织
|
||||||
|
await self._save_org_id(db, org_id)
|
||||||
|
|
||||||
|
# 从登录响应中提取用户信息
|
||||||
|
user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {}
|
||||||
|
sync_nickname = user_info.get("nickName") or user_info.get("username") or ""
|
||||||
|
sync_real_name = user_info.get("realName") or ""
|
||||||
|
sync_sex = int(user_info.get("sex") or 0)
|
||||||
|
sync_avatar = user_info.get("avatar") or ""
|
||||||
|
|
||||||
|
await set_session(user.id, {
|
||||||
|
"token": access_token,
|
||||||
|
"session_id": sid,
|
||||||
|
"platform_uid": platform_uid,
|
||||||
|
"org_id": org_id or cfg.get("orgId", ""),
|
||||||
|
"login_time": datetime.now().isoformat(),
|
||||||
|
# 缓存用户信息供 sync 使用
|
||||||
|
"nickname": sync_nickname,
|
||||||
|
"real_name": sync_real_name,
|
||||||
|
"sex": sync_sex,
|
||||||
|
"avatar": sync_avatar,
|
||||||
|
}, expire=86400)
|
||||||
|
|
||||||
|
# 更新本地数据库,同步平台用户信息
|
||||||
|
update_vals = dict(
|
||||||
|
status=2, session_token=access_token,
|
||||||
|
session_expires_at=datetime.now() + timedelta(hours=24),
|
||||||
|
last_login_at=datetime.now(),
|
||||||
|
platform_uid=platform_uid,
|
||||||
|
)
|
||||||
|
if sync_nickname: update_vals["nickname"] = sync_nickname
|
||||||
|
if sync_real_name: update_vals["real_name"] = sync_real_name
|
||||||
|
if sync_sex: update_vals["sex"] = sync_sex
|
||||||
|
if sync_avatar: update_vals["avatar_url"] = sync_avatar
|
||||||
|
await db.execute(update(VirtualUser).where(VirtualUser.id == user.id).values(**update_vals))
|
||||||
|
await db.commit()
|
||||||
|
await self._write_login_log(db, user, "login", sid)
|
||||||
|
logger.info(f"✅ [登录] {user.account} 成功 orgId={org_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.warning(f"[登录] {user.account} 无token: {body}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[登录] {user.account} 失败: HTTP={resp.status_code} {body}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
exc = e
|
||||||
|
logger.error(f"[登录] {user.account} 异常: {e}")
|
||||||
|
|
||||||
|
await db.execute(update(VirtualUser).where(VirtualUser.id == user.id).values(status=3))
|
||||||
|
await db.commit()
|
||||||
|
await self._write_login_log(db, user, "fail", error_msg=str(exc or "登录失败"))
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _fetch_org_id(
|
||||||
|
self, db: AsyncSession, token: str, platform_uid: str, cfg: dict
|
||||||
|
) -> str:
|
||||||
|
"""登录成功后,调用接口获取用户所属组织 orgId"""
|
||||||
|
biz = await self._biz_url(db)
|
||||||
|
headers = self._bearer(token)
|
||||||
|
# 尝试常见的用户信息接口
|
||||||
|
endpoints = [
|
||||||
|
f"/app/user/info",
|
||||||
|
f"/open/user/info",
|
||||||
|
f"/user/info",
|
||||||
|
]
|
||||||
|
for ep in endpoints:
|
||||||
|
try:
|
||||||
|
form = self._build_form({}, cfg)
|
||||||
|
async with httpx.AsyncClient(timeout=8) as c:
|
||||||
|
r = await c.get(
|
||||||
|
f"{biz}{ep}",
|
||||||
|
headers=headers,
|
||||||
|
params={k: v for k, v in form.items() if k not in ("username","password")}
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
d = r.json()
|
||||||
|
data = d.get("data") or {}
|
||||||
|
# 从各种可能的字段中提取 orgId
|
||||||
|
org_id = (
|
||||||
|
data.get("orgId") or data.get("defaultOrgId") or
|
||||||
|
data.get("org", {}).get("id") if isinstance(data.get("org"), dict) else None
|
||||||
|
)
|
||||||
|
if org_id:
|
||||||
|
return str(org_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"获取orgId失败({ep}): {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def _save_org_id(self, db: AsyncSession, org_id: str):
|
||||||
|
"""自动保存获取到的 orgId 到系统配置"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.config_key == "platform_org_id")
|
||||||
|
)
|
||||||
|
cfg = result.scalar_one_or_none()
|
||||||
|
if cfg:
|
||||||
|
cfg.config_value = org_id
|
||||||
|
else:
|
||||||
|
db.add(SystemConfig(
|
||||||
|
config_key="platform_org_id",
|
||||||
|
config_value=org_id,
|
||||||
|
description="平台组织Id(自动获取)"
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"✅ 自动获取并保存 orgId={org_id}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_token(raw) -> tuple[str, str]:
|
||||||
|
if isinstance(raw, str) and raw:
|
||||||
|
return raw, ""
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
token = (raw.get("access_token") or raw.get("accessToken") or raw.get("token") or "")
|
||||||
|
# openid 是平台用户ID(登录响应里 data.openid = data.userInfo.id)
|
||||||
|
uid = str(
|
||||||
|
raw.get("openid") or
|
||||||
|
raw.get("userId") or raw.get("user_id") or raw.get("id") or
|
||||||
|
(raw.get("userInfo") or {}).get("id") or ""
|
||||||
|
)
|
||||||
|
return token, uid
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
async def logout(self, db: AsyncSession, user_id: int):
|
||||||
|
user_r = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||||
|
user = user_r.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
sess = await get_session(user_id)
|
||||||
|
await self._write_login_log(db, user, "logout",
|
||||||
|
sess.get("session_id") if sess else None)
|
||||||
|
await delete_session(user_id)
|
||||||
|
await db.execute(update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||||||
|
status=0, session_token=None, session_expires_at=None))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def check_session(self, db: AsyncSession, user: VirtualUser) -> bool:
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return False
|
||||||
|
biz = await self._biz_url(db)
|
||||||
|
cfg = await self._client(db)
|
||||||
|
try:
|
||||||
|
params = self._build_form({}, cfg)
|
||||||
|
params.update({"orgId": cfg["orgId"] or "1", "pageNum": 1, "pageSize": 1, "status": "approved"})
|
||||||
|
async with httpx.AsyncClient(timeout=10) as c:
|
||||||
|
r = await c.get(f"{biz}/news/list", headers=self._bearer(sess["token"]), params=params)
|
||||||
|
if r.status_code == 200 and r.json().get("code") in [0, 200]:
|
||||||
|
return True
|
||||||
|
await self.logout(db, user.id)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ─── 新闻列表 ──────────────────────────────────────────────
|
||||||
|
async def get_news_list(self, db, user, count=5, interest_tags=None) -> list:
|
||||||
|
"""
|
||||||
|
GET /business/member/square/list 广场数据分页查询
|
||||||
|
type=1 表示新闻,orgId 选填(不填则查全平台新闻,无需配置 orgId)
|
||||||
|
返回字段:id(广场ID), recordId(新闻实际ID), title, orgId, orgName
|
||||||
|
"""
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return []
|
||||||
|
biz = await self._biz_url(db)
|
||||||
|
cfg = await self._client(db)
|
||||||
|
org_id = sess.get("org_id") or cfg.get("orgId") or ""
|
||||||
|
|
||||||
|
# 先查总数,再随机翻页,避免每次都取第1页相同内容
|
||||||
|
import math
|
||||||
|
# 第一次查询获取总页数
|
||||||
|
first_params = self._build_form({
|
||||||
|
"pageNum": 1,
|
||||||
|
"pageSize": 50,
|
||||||
|
"type": "1",
|
||||||
|
"isPlatformShow": "true",
|
||||||
|
"isAdmin": "false",
|
||||||
|
}, cfg)
|
||||||
|
if org_id:
|
||||||
|
first_params["orgId"] = org_id
|
||||||
|
|
||||||
|
total_pages = 1
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as _c:
|
||||||
|
_r = await _c.get(
|
||||||
|
f"{biz}/business/member/square/list",
|
||||||
|
headers=self._bearer(sess["token"]),
|
||||||
|
params=first_params
|
||||||
|
)
|
||||||
|
_d = _r.json()
|
||||||
|
if _d.get("code") in [0, 200]:
|
||||||
|
total_size = _d.get("data", {}).get("totalSize", 0)
|
||||||
|
total_pages = max(1, math.ceil(total_size / 50))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 随机选择一页
|
||||||
|
import random as _random
|
||||||
|
rand_page = _random.randint(1, min(total_pages, 10)) # 最多取前10页随机
|
||||||
|
|
||||||
|
params = self._build_form({
|
||||||
|
"pageNum": rand_page,
|
||||||
|
"pageSize": 50,
|
||||||
|
"type": "1",
|
||||||
|
"isPlatformShow": "true",
|
||||||
|
"isAdmin": "false",
|
||||||
|
}, cfg)
|
||||||
|
if org_id:
|
||||||
|
params["orgId"] = org_id # 选填,有则按组织过滤
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
r = await c.get(
|
||||||
|
f"{biz}/business/member/square/list",
|
||||||
|
headers=self._bearer(sess["token"]),
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
d = r.json()
|
||||||
|
if d.get("code") in [0, 200]:
|
||||||
|
nd = d.get("data", {})
|
||||||
|
items = nd.get("data") or nd.get("list") or nd.get("records") or []
|
||||||
|
# 过滤本人发布的文章
|
||||||
|
platform_uid = sess.get("platform_uid", "")
|
||||||
|
if platform_uid:
|
||||||
|
items = [x for x in items if x.get("createUser") != platform_uid]
|
||||||
|
# 过滤已知无效新闻(详情为空或不存在)
|
||||||
|
INVALID_IDS = {
|
||||||
|
"1965670408480907266","2029092495693975554","1960652956793597953",
|
||||||
|
"1960651987045347330","1960596408620838914","1960596083193180161",
|
||||||
|
"1960595664341594113",
|
||||||
|
}
|
||||||
|
items = [x for x in items
|
||||||
|
if (x.get("recordId") or x.get("id")) not in INVALID_IDS]
|
||||||
|
logger.info(f"[广场新闻] {user.account} 获取到 {len(items)} 条(已过滤本人+无效文章)")
|
||||||
|
import random as _rand
|
||||||
|
return _rand.sample(items, min(count, len(items))) if items else []
|
||||||
|
logger.warning(f"[广场新闻] {user.account} code={d.get('code')} msg={d.get('message')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[广场新闻] {user.account}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def read_news(self, db, user, news_id: str) -> bool:
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return False
|
||||||
|
biz = await self._biz_url(db)
|
||||||
|
cfg = await self._client(db)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as c:
|
||||||
|
r = await c.patch(
|
||||||
|
f"{biz}/news/read/{news_id}",
|
||||||
|
headers=self._bearer(sess["token"]),
|
||||||
|
data=self._build_form({}, cfg),
|
||||||
|
)
|
||||||
|
return r.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post_comment(self, db, user, news_id, news_title, content, news_author_id="", org_id="") -> tuple[bool, str]:
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return False, "未登录"
|
||||||
|
biz = await self._biz_url(db)
|
||||||
|
cfg = await self._client(db)
|
||||||
|
uid = sess.get("platform_uid", "")
|
||||||
|
# org_id 优先取文章自带的(从广场数据获取),否则取 session/配置
|
||||||
|
final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or ""
|
||||||
|
body = {
|
||||||
|
"module": "news", "topicId": news_id, "title": news_title,
|
||||||
|
"content": content, "orgId": final_org_id,
|
||||||
|
"toUserId": news_author_id or uid, "userId": uid,
|
||||||
|
"userName": user.nickname, "avatar": user.avatar_url or "",
|
||||||
|
}
|
||||||
|
return await self._json_post(f"{biz}/message/comment", self._bearer(sess["token"]), body)
|
||||||
|
|
||||||
|
async def post_reply(self, db, user, news_id, comment_id, content) -> tuple[bool, str]:
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return False, "未登录"
|
||||||
|
biz = await self._biz_url(db)
|
||||||
|
uid = sess.get("platform_uid", "")
|
||||||
|
body = {
|
||||||
|
"module": "news", "topicId": news_id, "commentId": comment_id,
|
||||||
|
"commentUserId": uid, "content": content,
|
||||||
|
"fromUserName": user.nickname, "avatar": user.avatar_url or "",
|
||||||
|
}
|
||||||
|
return await self._json_post(f"{biz}/message/comment/reply", self._bearer(sess["token"]), body)
|
||||||
|
|
||||||
|
async def get_comments(self, db, user, news_id) -> list:
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return []
|
||||||
|
biz = await self._biz_url(db)
|
||||||
|
cfg = await self._client(db)
|
||||||
|
try:
|
||||||
|
params = self._build_form({"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20}, cfg)
|
||||||
|
async with httpx.AsyncClient(timeout=10) as c:
|
||||||
|
r = await c.get(f"{biz}/message/comment", headers=self._bearer(sess["token"]), params=params)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json().get("data", {}).get("data") or []
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def like_news(self, db, user, news_id, org_id="", to_user_id="", title="") -> tuple[bool, str]:
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return False, "未登录"
|
||||||
|
biz = await self._biz_url(db)
|
||||||
|
uid = sess.get("platform_uid", "")
|
||||||
|
body = {
|
||||||
|
"module": "news",
|
||||||
|
"topicId": news_id,
|
||||||
|
"userId": uid,
|
||||||
|
"toUserId": to_user_id or uid,
|
||||||
|
"orgId": org_id or sess.get("org_id", "") or "",
|
||||||
|
"title": title,
|
||||||
|
}
|
||||||
|
return await self._json_post(f"{biz}/message/praise", self._bearer(sess["token"]), body)
|
||||||
|
|
||||||
|
async def collect_news(self, db, user, news_id, org_id="", to_user_id="", title="") -> tuple[bool, str]:
|
||||||
|
"""收藏新闻:复用点赞接口(平台收藏=点赞同一接口)"""
|
||||||
|
return await self.like_news(db, user, news_id, org_id=org_id, to_user_id=to_user_id, title=title)
|
||||||
|
|
||||||
|
async def forward_news(self, db, user, news_id) -> tuple[bool, str]:
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return False, "未登录"
|
||||||
|
biz = await self._biz_url(db)
|
||||||
|
cfg = await self._client(db)
|
||||||
|
org_id = sess.get("org_id") or cfg.get("orgId") or "1"
|
||||||
|
headers = self._bearer(sess["token"])
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8) as c:
|
||||||
|
await c.get(f"{biz}/news/share/wechat/{news_id}", headers=headers,
|
||||||
|
params=self._build_form({}, cfg))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
r = await c.post(
|
||||||
|
f"{biz}/points/forward/news/{org_id}",
|
||||||
|
headers=headers,
|
||||||
|
data=self._build_form({}, cfg),
|
||||||
|
)
|
||||||
|
return self._ok(r)
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bearer(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ok(resp: httpx.Response) -> tuple[bool, str]:
|
||||||
|
if resp.status_code in [200, 201]:
|
||||||
|
try:
|
||||||
|
d = resp.json()
|
||||||
|
if d.get("code") in [0, 200]:
|
||||||
|
return True, ""
|
||||||
|
return False, d.get("message") or "业务失败"
|
||||||
|
except Exception:
|
||||||
|
return True, ""
|
||||||
|
return False, f"HTTP {resp.status_code}"
|
||||||
|
|
||||||
|
async def _json_post(self, url, headers, body) -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
r = await c.post(url, json=body, headers=headers)
|
||||||
|
return self._ok(r)
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
async def _write_login_log(self, db, user, action, session_id=None, error_msg=None):
|
||||||
|
try:
|
||||||
|
log = LoginLog(
|
||||||
|
user_id=user.id, user_account=user.account,
|
||||||
|
action=action, session_id=session_id, error_msg=error_msg,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 取消互动 ────────────────────────────────────────────────────
|
||||||
|
async def cancel_like(self, db, user, news_id: str, org_id: str = "", to_user_id: str = "", title: str = "") -> tuple[bool, str]:
|
||||||
|
"""DELETE /message/praise/cancel 取消点赞"""
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return False, "未登录"
|
||||||
|
biz = await self._biz_url(db)
|
||||||
|
uid = sess.get("platform_uid", "")
|
||||||
|
body = {
|
||||||
|
"module": "news",
|
||||||
|
"topicId": news_id,
|
||||||
|
"userId": uid,
|
||||||
|
"toUserId": to_user_id or uid,
|
||||||
|
"orgId": org_id or sess.get("org_id", "") or "",
|
||||||
|
"title": title,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as c:
|
||||||
|
r = await c.delete(
|
||||||
|
f"{biz}/message/praise/cancel",
|
||||||
|
json=body,
|
||||||
|
headers=self._bearer(sess["token"])
|
||||||
|
)
|
||||||
|
d = r.json()
|
||||||
|
if d.get("code") in [0, 200]:
|
||||||
|
return True, ""
|
||||||
|
return False, d.get("message", "取消点赞失败")
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
async def cancel_comment(self, db, user, news_id: str, comment_id: str) -> tuple[bool, str]:
|
||||||
|
"""DELETE /message/comment/{topicId}/{id} 删除评论"""
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return False, "未登录"
|
||||||
|
biz = await self._biz_url(db)
|
||||||
|
cfg = await self._client(db)
|
||||||
|
# 签名参数放 formData,路径里是 topicId 和 comment_id
|
||||||
|
params = self._build_form({}, cfg)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as c:
|
||||||
|
r = await c.delete(
|
||||||
|
f"{biz}/message/comment/{news_id}/{comment_id}",
|
||||||
|
headers=self._bearer(sess["token"]),
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
d = r.json()
|
||||||
|
if d.get("code") in [0, 200]:
|
||||||
|
return True, ""
|
||||||
|
return False, d.get("message", "删除评论失败")
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
async def cancel_collect(self, db, user, news_id: str, org_id: str = "", to_user_id: str = "", title: str = "") -> tuple[bool, str]:
|
||||||
|
"""取消收藏(复用取消点赞接口)"""
|
||||||
|
return await self.cancel_like(db, user, news_id, org_id=org_id, to_user_id=to_user_id, title=title)
|
||||||
|
|
||||||
|
# ─── 修改目标系统用户信息 ─────────────────────────────────────
|
||||||
|
async def update_user_profile(
|
||||||
|
self, db: AsyncSession, user: VirtualUser,
|
||||||
|
nick_name: str = None, real_name: str = None,
|
||||||
|
sex: int = None, avatar: str = None,
|
||||||
|
description: str = None, email: str = None,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
调用 POST /usercenter/user/change/userInfo 修改用户信息
|
||||||
|
支持:昵称、真实姓名、性别、头像、简介、邮箱
|
||||||
|
同时同步更新本地数据库
|
||||||
|
"""
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return False, "用户未登录,请先登录"
|
||||||
|
|
||||||
|
auth = await self._auth_url(db)
|
||||||
|
cfg = await self._client(db)
|
||||||
|
token = sess.get("token", "")
|
||||||
|
platform_uid = sess.get("platform_uid", "")
|
||||||
|
|
||||||
|
if not platform_uid:
|
||||||
|
return False, "缺少平台用户ID,请重新登录"
|
||||||
|
|
||||||
|
# 构建请求体(只传有值的字段)
|
||||||
|
# 构建请求体,确保至少有 nickName 字段(平台 SQL 要求 SET 子句不为空)
|
||||||
|
body = {
|
||||||
|
"id": platform_uid,
|
||||||
|
"nickName": nick_name if nick_name is not None else (user.nickname or ""),
|
||||||
|
"realName": real_name if real_name is not None else (user.real_name or ""),
|
||||||
|
"sex": sex if sex is not None else (user.sex or 0),
|
||||||
|
}
|
||||||
|
if avatar is not None: body["avatar"] = avatar
|
||||||
|
if description is not None: body["description"] = description
|
||||||
|
if email is not None: body["email"] = email
|
||||||
|
|
||||||
|
# 使用 PATCH /v2/users/current 接口(支持修改昵称)
|
||||||
|
headers = dict(self._bearer(token))
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
r = await c.patch(
|
||||||
|
f"{auth}/v2/users/current",
|
||||||
|
json=body,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
d = r.json()
|
||||||
|
if d.get("code") in [0, 200]:
|
||||||
|
# 同步到本地数据库
|
||||||
|
local_vals = {}
|
||||||
|
if nick_name is not None: local_vals["nickname"] = nick_name
|
||||||
|
if real_name is not None: local_vals["real_name"] = real_name
|
||||||
|
if sex is not None: local_vals["sex"] = sex
|
||||||
|
if avatar is not None: local_vals["avatar_url"] = avatar
|
||||||
|
if local_vals:
|
||||||
|
from sqlalchemy import update
|
||||||
|
await db.execute(update(VirtualUser).where(
|
||||||
|
VirtualUser.id == user.id).values(**local_vals))
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"✅ 用户 {user.account} 信息已同步到目标系统")
|
||||||
|
return True, ""
|
||||||
|
err = d.get("message") or f"code={d.get('code')}"
|
||||||
|
logger.warning(f"[修改用户信息] {user.account} 失败: {err} body={r.text[:200]}")
|
||||||
|
return False, err
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[修改用户信息] {user.account} 异常: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
async def upload_avatar(
|
||||||
|
self, db: AsyncSession, user: VirtualUser, file_bytes: bytes, filename: str
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
上传头像图片到平台 filecenter,返回图片 URL
|
||||||
|
POST /filecenter/fileUpload (multipart/form-data)
|
||||||
|
"""
|
||||||
|
sess = await get_session(user.id)
|
||||||
|
if not sess:
|
||||||
|
return False, "用户未登录"
|
||||||
|
|
||||||
|
cfg = await self._client(db)
|
||||||
|
token = sess.get("token", "")
|
||||||
|
|
||||||
|
# filecenter 服务地址
|
||||||
|
biz_base = await self._biz_url(db)
|
||||||
|
# filecenter 与 huihuibusiness 同网关,替换服务名
|
||||||
|
filecenter_url = biz_base.replace("/huihuibusiness", "/filecenter")
|
||||||
|
|
||||||
|
sign_params = self._build_form({"module": "userInfo", "service": "kccloud"}, cfg)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import mimetypes
|
||||||
|
mime = mimetypes.guess_type(filename)[0] or "image/jpeg"
|
||||||
|
files = {"file": (filename, file_bytes, mime)}
|
||||||
|
async with httpx.AsyncClient(timeout=30) as c:
|
||||||
|
r = await c.post(
|
||||||
|
f"{filecenter_url}/fileUpload",
|
||||||
|
files=files,
|
||||||
|
data=sign_params,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
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 ""
|
||||||
|
logger.info(f"✅ 头像上传成功: {url}")
|
||||||
|
return True, url
|
||||||
|
return False, d.get("message") or "上传失败"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
news_service = NewsPlatformService()
|
||||||
402
backend/app/services/scheduler.py
Normal file
402
backend/app/services/scheduler.py
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
"""调度服务 - 定时自动互动、会话校验"""
|
||||||
|
import random
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
from sqlalchemy import select, update, func
|
||||||
|
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.core.logger import logger
|
||||||
|
from app.models import VirtualUser, UserPersonality, InteractionRecord, SystemConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerService:
|
||||||
|
def __init__(self):
|
||||||
|
self.scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def run_once_now(self, db=None):
|
||||||
|
"""立即执行一次互动,不受时间段限制"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
logger.info("⚡ 立即触发互动任务")
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result_r = await session.execute(
|
||||||
|
select(VirtualUser).where(
|
||||||
|
VirtualUser.status == 2,
|
||||||
|
VirtualUser.is_enabled == 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
users = result_r.scalars().all()
|
||||||
|
if not users:
|
||||||
|
return {"message": "没有已登录的用户", "triggered": 0}
|
||||||
|
import random
|
||||||
|
selected = random.sample(users, min(5, len(users)))
|
||||||
|
import asyncio
|
||||||
|
tasks = [self._execute_user_interaction(u.id) for u in selected]
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
return {"triggered": len(selected), "users": [u.account for u in selected]}
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
# 会话校验:每10分钟
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._check_sessions, IntervalTrigger(minutes=10),
|
||||||
|
id="check_sessions", replace_existing=True
|
||||||
|
)
|
||||||
|
# 互动任务:每5分钟检查一次(内部判断是否在活跃时间段)
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._run_interactions, IntervalTrigger(minutes=5),
|
||||||
|
id="run_interactions", replace_existing=True
|
||||||
|
)
|
||||||
|
# 每日零点重置计数
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._daily_reset, "cron", hour=16, minute=0, # 北京时间 00:00 = UTC 16:00
|
||||||
|
id="daily_reset", replace_existing=True
|
||||||
|
)
|
||||||
|
self.scheduler.start()
|
||||||
|
self._running = True
|
||||||
|
logger.info("调度器已启动")
|
||||||
|
# 记录启动时间
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await self._set_config(db, "system_start_time", datetime.now().isoformat())
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
if self.scheduler.running:
|
||||||
|
self.scheduler.shutdown(wait=False)
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def _get_config(self, db, key: str, default=None):
|
||||||
|
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == key))
|
||||||
|
cfg = result.scalar_one_or_none()
|
||||||
|
return cfg.config_value if cfg else default
|
||||||
|
|
||||||
|
async def _set_config(self, db, key: str, value: str):
|
||||||
|
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == key))
|
||||||
|
cfg = result.scalar_one_or_none()
|
||||||
|
if cfg:
|
||||||
|
cfg.config_value = value
|
||||||
|
else:
|
||||||
|
db.add(SystemConfig(config_key=key, config_value=value))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def _check_sessions(self):
|
||||||
|
"""定时校验登录状态"""
|
||||||
|
from app.services.news_service import news_service
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(VirtualUser).where(VirtualUser.status == 2, VirtualUser.is_enabled == 1)
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
valid = await news_service.check_session(db, user)
|
||||||
|
if not valid:
|
||||||
|
logger.warning(f"用户 {user.account} 会话失效,尝试重登")
|
||||||
|
await news_service.login(db, user)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"会话校验异常 {user.account}: {e}")
|
||||||
|
|
||||||
|
async def _run_interactions(self):
|
||||||
|
"""执行互动任务"""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# 检查调度器开关
|
||||||
|
enabled = await self._get_config(db, "scheduler_enabled", "true")
|
||||||
|
if enabled != "true":
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查Token限额
|
||||||
|
token_limited = await self._get_config(db, "token_limit_reached", "false")
|
||||||
|
if token_limited == "true":
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查互动时间段(北京时间 UTC+8)
|
||||||
|
from datetime import timezone, timedelta
|
||||||
|
tz_beijing = timezone(timedelta(hours=8))
|
||||||
|
now_bj = datetime.now(tz_beijing)
|
||||||
|
now_time = now_bj.strftime("%H:%M")
|
||||||
|
start_str = await self._get_config(db, "interact_time_start", "08:00")
|
||||||
|
end_str = await self._get_config(db, "interact_time_end", "22:00")
|
||||||
|
if not (start_str <= now_time <= end_str):
|
||||||
|
logger.debug(f"[调度] 当前北京时间 {now_time} 不在互动时段 {start_str}-{end_str}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取最小互动间隔(秒)
|
||||||
|
min_interval = int(await self._get_config(db, "interact_min_interval", "300"))
|
||||||
|
|
||||||
|
# 获取最大并发
|
||||||
|
max_concurrent = int(await self._get_config(db, "max_concurrent_users", "5"))
|
||||||
|
|
||||||
|
# 获取已登录、启用的用户
|
||||||
|
query = select(VirtualUser).where(
|
||||||
|
VirtualUser.status == 2,
|
||||||
|
VirtualUser.is_enabled == 1,
|
||||||
|
)
|
||||||
|
if max_concurrent > 0:
|
||||||
|
query = query.limit(max_concurrent)
|
||||||
|
result = await db.execute(query)
|
||||||
|
all_users = result.scalars().all()
|
||||||
|
|
||||||
|
# 没有已登录用户时,尝试登录未登录用户
|
||||||
|
if not all_users:
|
||||||
|
await self._try_login_users(db)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查互动间隔:过滤掉最近 min_interval 秒内已互动的用户
|
||||||
|
now_utc = datetime.utcnow()
|
||||||
|
eligible = []
|
||||||
|
for u in all_users:
|
||||||
|
if u.last_interact_at is None:
|
||||||
|
eligible.append(u)
|
||||||
|
else:
|
||||||
|
elapsed = (now_utc - u.last_interact_at).total_seconds()
|
||||||
|
if elapsed >= min_interval:
|
||||||
|
eligible.append(u)
|
||||||
|
|
||||||
|
if not eligible:
|
||||||
|
logger.debug(f"[调度] 所有用户在 {min_interval}s 内已互动,跳过本次")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"[调度] {len(eligible)}/{len(all_users)} 个用户满足间隔要求,开始互动")
|
||||||
|
|
||||||
|
# 随机选取用户执行互动
|
||||||
|
for user in eligible:
|
||||||
|
if random.random() < 0.6:
|
||||||
|
asyncio.create_task(self._execute_user_interaction(user.id))
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""执行单用户互动 - 基于真实接口"""
|
||||||
|
from app.services.news_service import news_service
|
||||||
|
from app.services.ai_service import ai_service
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
user_result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
if not user or user.status != 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查今日评论限额
|
||||||
|
can_comment = True
|
||||||
|
if user.today_comment_count >= user.daily_comment_limit:
|
||||||
|
can_comment = False
|
||||||
|
logger.info(f'用户 ' + user.account + ' 今日评论已达上限,仍执行点赞/收藏/转发')
|
||||||
|
|
||||||
|
# 获取人格
|
||||||
|
from app.models import UserPersonality
|
||||||
|
p_result = await db.execute(
|
||||||
|
select(UserPersonality).where(UserPersonality.user_id == user_id)
|
||||||
|
)
|
||||||
|
personality = p_result.scalar_one_or_none()
|
||||||
|
interest_tags = personality.interest_tags if personality else []
|
||||||
|
|
||||||
|
# 获取新闻列表(基于接口 GET /news/list)
|
||||||
|
articles = await news_service.get_news_list(
|
||||||
|
db, user, count=5, interest_tags=interest_tags
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
article = random.choice(articles)
|
||||||
|
# 接口返回字段: id/newsTitle/content/digest/createUser
|
||||||
|
# 广场接口字段:recordId=新闻实际ID, id=广场记录ID, title=标题
|
||||||
|
news_id = str(article.get("recordId") or article.get("id", ""))
|
||||||
|
news_title = article.get("title") or article.get("newsTitle") or "未知文章"
|
||||||
|
news_content = article.get("content") or article.get("digest") or news_title
|
||||||
|
news_author = str(article.get("createUser") or "")
|
||||||
|
# 从广场数据中顺带获取 orgId
|
||||||
|
article_org_id = str(article.get("orgId") or "")
|
||||||
|
|
||||||
|
if not news_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 读取互动概率
|
||||||
|
comment_prob = float(await self._get_config_from_db(db, "comment_probability", "0.4"))
|
||||||
|
reply_prob = float(await self._get_config_from_db(db, "reply_probability", "0.2"))
|
||||||
|
like_prob = float(await self._get_config_from_db(db, "like_probability", "0.6"))
|
||||||
|
collect_prob = float(await self._get_config_from_db(db, "collect_probability", "0.3"))
|
||||||
|
forward_prob = float(await self._get_config_from_db(db, "forward_probability", "0.15"))
|
||||||
|
|
||||||
|
interactions_done = []
|
||||||
|
|
||||||
|
# ① 先记录阅读(每次必做,模拟真实用户打开文章)
|
||||||
|
await news_service.read_news(db, user, news_id)
|
||||||
|
|
||||||
|
# ② 点赞
|
||||||
|
if random.random() < like_prob:
|
||||||
|
success, err = await news_service.like_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
|
||||||
|
await self._save_record(db, user, news_id, news_title, "like", None, 0, success, err)
|
||||||
|
if success:
|
||||||
|
interactions_done.append("like")
|
||||||
|
await self._incr_total(db, user_id)
|
||||||
|
|
||||||
|
# ③ 收藏(阅读+点赞组合模拟)
|
||||||
|
if 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")
|
||||||
|
|
||||||
|
# ④ 转发(调用 /points/forward/news/{orgId})
|
||||||
|
if 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)
|
||||||
|
|
||||||
|
# ⑤ 评论(AI生成内容,调用 POST /message/comment)
|
||||||
|
if can_comment and random.random() < comment_prob and personality:
|
||||||
|
style_prompt = personality.comment_style_prompt or ""
|
||||||
|
# 字数上限最多80字,避免超出 max_tokens 被截断
|
||||||
|
safe_word_max = min(personality.word_count_max, 80)
|
||||||
|
comment_text, tokens = await ai_service.generate_comment(
|
||||||
|
db, news_title, news_content,
|
||||||
|
style_prompt, personality.word_count_min, safe_word_max
|
||||||
|
)
|
||||||
|
if comment_text:
|
||||||
|
success, err = await news_service.post_comment(
|
||||||
|
db, user, news_id, news_title, comment_text,
|
||||||
|
news_author_id=news_author, org_id=article_org_id
|
||||||
|
)
|
||||||
|
await self._save_record(
|
||||||
|
db, user, news_id, news_title, "comment",
|
||||||
|
comment_text, tokens, success, err
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
interactions_done.append("comment")
|
||||||
|
await db.execute(
|
||||||
|
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||||||
|
today_comment_count=VirtualUser.today_comment_count + 1,
|
||||||
|
total_interactions=VirtualUser.total_interactions + 1,
|
||||||
|
last_interact_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ⑥ 回复评论(评论成功后,随机回复别人的评论)
|
||||||
|
if random.random() < reply_prob:
|
||||||
|
existing = await news_service.get_comments(db, user, news_id)
|
||||||
|
if existing:
|
||||||
|
target = random.choice(existing)
|
||||||
|
cid = str(target.get("id") or target.get("commentId") or "")
|
||||||
|
parent_content = target.get("content") or ""
|
||||||
|
if cid:
|
||||||
|
reply_text, r_tokens = await ai_service.generate_reply(
|
||||||
|
db, news_title, parent_content,
|
||||||
|
style_prompt,
|
||||||
|
personality.word_count_min,
|
||||||
|
personality.word_count_max
|
||||||
|
)
|
||||||
|
if reply_text:
|
||||||
|
r_ok, r_err = await news_service.post_reply(
|
||||||
|
db, user, news_id, cid, reply_text
|
||||||
|
)
|
||||||
|
await self._save_record(
|
||||||
|
db, user, news_id, news_title, "reply",
|
||||||
|
reply_text, r_tokens, r_ok, r_err,
|
||||||
|
parent_comment_id=cid
|
||||||
|
)
|
||||||
|
if r_ok:
|
||||||
|
interactions_done.append("reply")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"👤 {user.account} 互动完成: {interactions_done} [新闻: {news_title[:20]}]")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"用户 {user_id} 互动异常: {e}")
|
||||||
|
|
||||||
|
async def _incr_total(self, db, user_id: int):
|
||||||
|
await db.execute(
|
||||||
|
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||||||
|
total_interactions=VirtualUser.total_interactions + 1,
|
||||||
|
last_interact_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
251
backend/app/services/stats_service.py
Normal file
251
backend/app/services/stats_service.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"""数据统计服务"""
|
||||||
|
from datetime import datetime, date, timedelta, timezone
|
||||||
|
|
||||||
|
def _fmt_dt(dt):
|
||||||
|
"""统一输出 UTC 时间,带时区标识,让前端正确解析为 +8"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
# 数据库存的是 UTC,补上时区信息
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.isoformat()
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, and_
|
||||||
|
|
||||||
|
from app.models import VirtualUser, InteractionRecord, TokenStat, SystemConfig
|
||||||
|
from app.core.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class StatsService:
|
||||||
|
|
||||||
|
async def get_dashboard(self, db: AsyncSession) -> dict:
|
||||||
|
"""获取控制台数据"""
|
||||||
|
today = date.today()
|
||||||
|
now = datetime.now()
|
||||||
|
month_start = today.replace(day=1)
|
||||||
|
|
||||||
|
# 用户统计
|
||||||
|
user_stats = await self._get_user_stats(db)
|
||||||
|
|
||||||
|
# 今日互动统计
|
||||||
|
today_stats = await self._get_today_stats(db, today)
|
||||||
|
|
||||||
|
# 本月互动统计
|
||||||
|
monthly_stats = await self._get_monthly_stats(db, month_start, today)
|
||||||
|
|
||||||
|
# Token统计
|
||||||
|
token_stats = await self._get_token_stats(db, today)
|
||||||
|
|
||||||
|
# 系统状态
|
||||||
|
system_status = await self._get_system_status(db, now)
|
||||||
|
|
||||||
|
# 在线用户数
|
||||||
|
online_count_result = await db.execute(
|
||||||
|
select(func.count()).where(VirtualUser.status == 2)
|
||||||
|
)
|
||||||
|
online_count = online_count_result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_stats": user_stats,
|
||||||
|
"today_interactions": today_stats,
|
||||||
|
"monthly_stats": monthly_stats,
|
||||||
|
"token_stats": token_stats,
|
||||||
|
"system_status": system_status,
|
||||||
|
"online_users": online_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_user_stats(self, db: AsyncSession) -> dict:
|
||||||
|
total = await db.execute(select(func.count()).select_from(VirtualUser))
|
||||||
|
normal = await db.execute(select(func.count()).where(VirtualUser.is_enabled == 1))
|
||||||
|
banned = await db.execute(select(func.count()).where(VirtualUser.status == 4))
|
||||||
|
abnormal = await db.execute(select(func.count()).where(VirtualUser.status == 3))
|
||||||
|
return {
|
||||||
|
"total": total.scalar() or 0,
|
||||||
|
"normal": normal.scalar() or 0,
|
||||||
|
"banned": banned.scalar() or 0,
|
||||||
|
"abnormal": abnormal.scalar() or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_today_stats(self, db: AsyncSession, today: date) -> dict:
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
InteractionRecord.interact_type,
|
||||||
|
func.count().label("cnt"),
|
||||||
|
).where(
|
||||||
|
func.date(InteractionRecord.executed_at) == today,
|
||||||
|
InteractionRecord.status == 1,
|
||||||
|
).group_by(InteractionRecord.interact_type)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
stats = {"comment": 0, "reply": 0, "like": 0, "collect": 0, "forward": 0, "total": 0}
|
||||||
|
for row in rows:
|
||||||
|
if row.interact_type in stats:
|
||||||
|
stats[row.interact_type] = row.cnt
|
||||||
|
stats["total"] += row.cnt
|
||||||
|
return stats
|
||||||
|
|
||||||
|
async def _get_monthly_stats(self, db: AsyncSession, month_start: date, today: date) -> dict:
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count()).where(
|
||||||
|
InteractionRecord.executed_at >= month_start,
|
||||||
|
InteractionRecord.status == 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {"total": result.scalar() or 0, "month_start": month_start.isoformat()}
|
||||||
|
|
||||||
|
async def _get_token_stats(self, db: AsyncSession, today: date) -> dict:
|
||||||
|
# 今日
|
||||||
|
today_stat = await db.execute(select(TokenStat).where(TokenStat.stat_date == today))
|
||||||
|
today_row = today_stat.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 每日限额
|
||||||
|
limit_cfg = await db.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.config_key == "daily_token_limit")
|
||||||
|
)
|
||||||
|
limit_row = limit_cfg.scalar_one_or_none()
|
||||||
|
daily_limit = int(limit_row.config_value) if limit_row else 100000
|
||||||
|
|
||||||
|
today_used = today_row.total_tokens if today_row else 0
|
||||||
|
return {
|
||||||
|
"today_used": today_used,
|
||||||
|
"daily_limit": daily_limit,
|
||||||
|
"remaining": max(0, daily_limit - today_used),
|
||||||
|
"today_calls": today_row.call_count if today_row else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_system_status(self, db: AsyncSession, now: datetime) -> dict:
|
||||||
|
start_cfg = await db.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.config_key == "system_start_time")
|
||||||
|
)
|
||||||
|
start_row = start_cfg.scalar_one_or_none()
|
||||||
|
uptime = ""
|
||||||
|
if start_row and start_row.config_value:
|
||||||
|
try:
|
||||||
|
start_time = datetime.fromisoformat(start_row.config_value)
|
||||||
|
delta = now - start_time
|
||||||
|
hours, rem = divmod(int(delta.total_seconds()), 3600)
|
||||||
|
mins = rem // 60
|
||||||
|
uptime = f"{hours}小时{mins}分钟"
|
||||||
|
except Exception:
|
||||||
|
uptime = "未知"
|
||||||
|
|
||||||
|
scheduler_cfg = await db.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.config_key == "scheduler_enabled")
|
||||||
|
)
|
||||||
|
scheduler_row = scheduler_cfg.scalar_one_or_none()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"uptime": uptime,
|
||||||
|
"scheduler_enabled": (scheduler_row.config_value == "true") if scheduler_row else True,
|
||||||
|
"current_time": now.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_token_trend(self, db: AsyncSession, days: int = 30) -> list:
|
||||||
|
"""Token消耗趋势(近N天)"""
|
||||||
|
end_date = date.today()
|
||||||
|
start_date = end_date - timedelta(days=days - 1)
|
||||||
|
result = await db.execute(
|
||||||
|
select(TokenStat).where(
|
||||||
|
TokenStat.stat_date >= start_date,
|
||||||
|
TokenStat.stat_date <= end_date,
|
||||||
|
).order_by(TokenStat.stat_date)
|
||||||
|
)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
stat_map = {r.stat_date.isoformat(): r.total_tokens for r in rows}
|
||||||
|
|
||||||
|
trend = []
|
||||||
|
for i in range(days):
|
||||||
|
d = (start_date + timedelta(days=i)).isoformat()
|
||||||
|
trend.append({"date": d, "tokens": stat_map.get(d, 0)})
|
||||||
|
return trend
|
||||||
|
|
||||||
|
async def get_monthly_token_trend(self, db: AsyncSession) -> list:
|
||||||
|
"""近12个月Token消耗"""
|
||||||
|
today = date.today()
|
||||||
|
months = []
|
||||||
|
for i in range(11, -1, -1):
|
||||||
|
if today.month - i <= 0:
|
||||||
|
year = today.year - 1
|
||||||
|
month = today.month - i + 12
|
||||||
|
else:
|
||||||
|
year = today.year
|
||||||
|
month = today.month - i
|
||||||
|
months.append((year, month))
|
||||||
|
|
||||||
|
trend = []
|
||||||
|
for year, month in months:
|
||||||
|
start = date(year, month, 1)
|
||||||
|
if month == 12:
|
||||||
|
end = date(year + 1, 1, 1) - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
end = date(year, month + 1, 1) - timedelta(days=1)
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.sum(TokenStat.total_tokens)).where(
|
||||||
|
TokenStat.stat_date >= start, TokenStat.stat_date <= end
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total = result.scalar() or 0
|
||||||
|
trend.append({"month": f"{year}-{month:02d}", "tokens": total})
|
||||||
|
return trend
|
||||||
|
|
||||||
|
async def get_interaction_records(
|
||||||
|
self, db: AsyncSession,
|
||||||
|
page: int = 1, page_size: int = 20,
|
||||||
|
user_id: int = None, interact_type: str = None,
|
||||||
|
status: int = None, start_date: str = None,
|
||||||
|
end_date: str = None, keyword: str = None
|
||||||
|
) -> dict:
|
||||||
|
query = select(InteractionRecord)
|
||||||
|
conditions = []
|
||||||
|
if user_id:
|
||||||
|
conditions.append(InteractionRecord.user_id == user_id)
|
||||||
|
if interact_type:
|
||||||
|
conditions.append(InteractionRecord.interact_type == interact_type)
|
||||||
|
if status is not None:
|
||||||
|
conditions.append(InteractionRecord.status == status)
|
||||||
|
if start_date:
|
||||||
|
conditions.append(InteractionRecord.executed_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
conditions.append(InteractionRecord.executed_at <= end_date + " 23:59:59")
|
||||||
|
if keyword:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
conditions.append(
|
||||||
|
or_(InteractionRecord.article_title.like(f"%{keyword}%"),
|
||||||
|
InteractionRecord.content.like(f"%{keyword}%"),
|
||||||
|
InteractionRecord.user_nickname.like(f"%{keyword}%"))
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
query = query.where(and_(*conditions))
|
||||||
|
|
||||||
|
count_q = select(func.count()).select_from(query.subquery())
|
||||||
|
total = (await db.execute(count_q)).scalar()
|
||||||
|
|
||||||
|
query = query.order_by(InteractionRecord.executed_at.desc()).offset(
|
||||||
|
(page - 1) * page_size
|
||||||
|
).limit(page_size)
|
||||||
|
result = await db.execute(query)
|
||||||
|
records = result.scalars().all()
|
||||||
|
|
||||||
|
INTERACT_LABELS = {
|
||||||
|
"comment": "评论", "reply": "回复", "like": "点赞",
|
||||||
|
"collect": "收藏", "forward": "转发"
|
||||||
|
}
|
||||||
|
STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败"}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in records:
|
||||||
|
items.append({
|
||||||
|
"id": r.id, "user_id": r.user_id,
|
||||||
|
"user_nickname": r.user_nickname, "user_account": r.user_account,
|
||||||
|
"article_id": r.article_id, "article_title": r.article_title,
|
||||||
|
"interact_type": r.interact_type,
|
||||||
|
"interact_type_label": INTERACT_LABELS.get(r.interact_type, r.interact_type),
|
||||||
|
"content": r.content, "token_consumed": r.token_consumed,
|
||||||
|
"status": r.status, "status_label": STATUS_LABELS.get(r.status, "未知"),
|
||||||
|
"error_msg": r.error_msg, "retry_count": r.retry_count,
|
||||||
|
"executed_at": _fmt_dt(r.executed_at),
|
||||||
|
})
|
||||||
|
return {"total": total, "page": page, "page_size": page_size, "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
stats_service = StatsService()
|
||||||
358
backend/app/services/user_service.py
Normal file
358
backend/app/services/user_service.py
Normal file
@@ -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()
|
||||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
49
backend/app/utils/crypto.py
Normal file
49
backend/app/utils/crypto.py
Normal file
@@ -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]
|
||||||
24
backend/requirements.txt
Normal file
24
backend/requirements.txt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
|
pandas==2.2.3
|
||||||
|
openpyxl==3.1.5
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
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
|
||||||
82
docker-compose.yml
Normal file
82
docker-compose.yml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
services:
|
||||||
|
ai-virtual-backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: ai-virtual-backend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=ai-virtual-mysql
|
||||||
|
- DB_PORT=3306
|
||||||
|
- DB_USER=aivirtual
|
||||||
|
- DB_PASSWORD=AiVirtual2024
|
||||||
|
- DB_NAME=ai_virtual_news
|
||||||
|
- REDIS_HOST=ai-virtual-redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
- AES_KEY=your-aes-key-32-chars-change-now!
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
volumes:
|
||||||
|
- ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建
|
||||||
|
- ./backend/logs:/app/logs
|
||||||
|
- ./backend/config:/app/config
|
||||||
|
depends_on:
|
||||||
|
- ai-virtual-mysql
|
||||||
|
- ai-virtual-redis
|
||||||
|
networks:
|
||||||
|
- ai-virtual-net
|
||||||
|
|
||||||
|
ai-virtual-frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: ai-virtual-frontend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "9000:80"
|
||||||
|
depends_on:
|
||||||
|
- ai-virtual-backend
|
||||||
|
networks:
|
||||||
|
- ai-virtual-net
|
||||||
|
|
||||||
|
ai-virtual-mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: ai-virtual-mysql
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- MYSQL_ROOT_PASSWORD=Root2024
|
||||||
|
- MYSQL_DATABASE=ai_virtual_news
|
||||||
|
- MYSQL_USER=aivirtual
|
||||||
|
- MYSQL_PASSWORD=AiVirtual2024
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- ai-virtual-net
|
||||||
|
|
||||||
|
ai-virtual-redis:
|
||||||
|
image: redis:6.0-alpine
|
||||||
|
container_name: ai-virtual-redis
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- ai-virtual-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ai-virtual-net:
|
||||||
|
driver: bridge
|
||||||
167
docker/mysql/init.sql
Normal file
167
docker/mysql/init.sql
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
-- AI虚拟用户新闻互动系统 数据库初始化脚本
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- 虚拟用户表
|
||||||
|
CREATE TABLE IF NOT EXISTS `virtual_users` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`nickname` varchar(64) NOT NULL COMMENT '昵称',
|
||||||
|
`account` varchar(128) NOT NULL COMMENT '新闻平台账号',
|
||||||
|
`password_enc` varchar(512) NOT NULL COMMENT 'AES加密密码',
|
||||||
|
`avatar_url` varchar(512) DEFAULT NULL COMMENT '头像URL',
|
||||||
|
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0未登录 1登录中 2已登录 3登录失效 4封禁',
|
||||||
|
`activity_level` tinyint NOT NULL DEFAULT 1 COMMENT '0低 1中 2高',
|
||||||
|
`daily_comment_limit` int NOT NULL DEFAULT 10 COMMENT '每日最大评论次数',
|
||||||
|
`daily_like_limit` int NOT NULL DEFAULT 30 COMMENT '每日最大点赞次数',
|
||||||
|
`today_comment_count` int NOT NULL DEFAULT 0 COMMENT '今日已评论次数',
|
||||||
|
`today_like_count` int NOT NULL DEFAULT 0 COMMENT '今日已点赞次数',
|
||||||
|
`total_interactions` int NOT NULL DEFAULT 0 COMMENT '累计互动次数',
|
||||||
|
`session_token` text DEFAULT NULL COMMENT '当前会话Token',
|
||||||
|
`session_expires_at` datetime DEFAULT NULL COMMENT '会话过期时间',
|
||||||
|
`last_login_at` datetime DEFAULT NULL COMMENT '最后登录时间',
|
||||||
|
`last_interact_at` datetime DEFAULT NULL COMMENT '最后互动时间',
|
||||||
|
`real_name` varchar(64) DEFAULT NULL COMMENT '真实姓名(平台同步)',
|
||||||
|
`sex` tinyint(1) NOT NULL DEFAULT 0 COMMENT '性别 0未知 1男 2女',
|
||||||
|
`platform_uid` varchar(64) DEFAULT NULL COMMENT '平台用户ID',
|
||||||
|
`remark` varchar(256) DEFAULT NULL COMMENT '备注',
|
||||||
|
`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0禁用',
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_account` (`account`),
|
||||||
|
UNIQUE KEY `uk_nickname` (`nickname`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='虚拟用户表';
|
||||||
|
|
||||||
|
-- 用户人格表
|
||||||
|
CREATE TABLE IF NOT EXISTS `user_personalities` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` bigint NOT NULL COMMENT '用户ID',
|
||||||
|
`character_type` varchar(32) DEFAULT NULL COMMENT '性格类型:开朗/内敛/毒舌/温和',
|
||||||
|
`language_style` varchar(32) DEFAULT NULL COMMENT '语言风格:严肃/幽默/文艺/吐槽',
|
||||||
|
`interest_tags` json DEFAULT NULL COMMENT '兴趣偏好JSON数组',
|
||||||
|
`interact_tendency` varchar(32) DEFAULT NULL COMMENT '互动倾向:爱评论/爱点赞/潜水',
|
||||||
|
`word_count_min` int DEFAULT 20 COMMENT '最少字数',
|
||||||
|
`word_count_max` int DEFAULT 100 COMMENT '最多字数',
|
||||||
|
`personality_desc` text DEFAULT NULL COMMENT 'AI生成的人格描述',
|
||||||
|
`comment_style_prompt` text DEFAULT NULL COMMENT '评论风格提示词',
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_user_id` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户人格表';
|
||||||
|
|
||||||
|
-- 互动记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS `interaction_records` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` bigint NOT NULL COMMENT '虚拟用户ID',
|
||||||
|
`user_nickname` varchar(64) DEFAULT NULL COMMENT '用户昵称',
|
||||||
|
`user_account` varchar(128) DEFAULT NULL COMMENT '用户账号',
|
||||||
|
`article_id` varchar(64) DEFAULT NULL COMMENT '文章ID',
|
||||||
|
`article_title` varchar(256) DEFAULT NULL COMMENT '文章标题',
|
||||||
|
`interact_type` varchar(16) NOT NULL COMMENT 'comment/reply/like/collect/forward',
|
||||||
|
`content` text DEFAULT NULL COMMENT '评论/回复内容',
|
||||||
|
`parent_comment_id` varchar(64) DEFAULT NULL COMMENT '父评论ID(回复时用)',
|
||||||
|
`session_id` varchar(128) DEFAULT NULL COMMENT '登录会话ID',
|
||||||
|
`token_consumed` int DEFAULT 0 COMMENT '消耗Token数',
|
||||||
|
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0执行中 1成功 2失败',
|
||||||
|
`error_msg` varchar(512) DEFAULT NULL COMMENT '失败原因',
|
||||||
|
`retry_count` tinyint DEFAULT 0 COMMENT '重试次数',
|
||||||
|
`executed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_user_id` (`user_id`),
|
||||||
|
KEY `idx_executed_at` (`executed_at`),
|
||||||
|
KEY `idx_interact_type` (`interact_type`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='互动记录表';
|
||||||
|
|
||||||
|
-- Token消耗统计表
|
||||||
|
CREATE TABLE IF NOT EXISTS `token_stats` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`stat_date` date NOT NULL COMMENT '统计日期',
|
||||||
|
`model_name` varchar(64) DEFAULT NULL COMMENT '模型名称',
|
||||||
|
`total_tokens` int NOT NULL DEFAULT 0 COMMENT '当日消耗总Token',
|
||||||
|
`prompt_tokens` int NOT NULL DEFAULT 0 COMMENT 'Prompt Token',
|
||||||
|
`completion_tokens` int NOT NULL DEFAULT 0 COMMENT 'Completion Token',
|
||||||
|
`call_count` int NOT NULL DEFAULT 0 COMMENT '调用次数',
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_stat_date` (`stat_date`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Token消耗统计表';
|
||||||
|
|
||||||
|
-- AI模型配置表
|
||||||
|
CREATE TABLE IF NOT EXISTS `ai_model_configs` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`model_name` varchar(64) NOT NULL COMMENT '模型名称',
|
||||||
|
`provider` varchar(32) NOT NULL COMMENT 'openai/zhipu/wenxin/qianwen/local',
|
||||||
|
`api_base_url` varchar(256) DEFAULT NULL COMMENT 'API地址',
|
||||||
|
`api_key_enc` varchar(512) DEFAULT NULL COMMENT '加密API Key',
|
||||||
|
`model_version` varchar(64) DEFAULT NULL COMMENT '模型版本',
|
||||||
|
`temperature` float DEFAULT 0.7 COMMENT '温度',
|
||||||
|
`max_tokens` int DEFAULT 1000 COMMENT '最大Token',
|
||||||
|
`timeout_seconds` int DEFAULT 30 COMMENT '超时秒数',
|
||||||
|
`is_default` tinyint NOT NULL DEFAULT 0 COMMENT '是否默认模型',
|
||||||
|
`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI模型配置表';
|
||||||
|
|
||||||
|
-- 系统配置表
|
||||||
|
CREATE TABLE IF NOT EXISTS `system_configs` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`config_key` varchar(64) NOT NULL COMMENT '配置键',
|
||||||
|
`config_value` text DEFAULT NULL COMMENT '配置值',
|
||||||
|
`config_type` varchar(16) DEFAULT 'string' COMMENT '类型:string/int/json/bool',
|
||||||
|
`description` varchar(256) DEFAULT NULL COMMENT '说明',
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_config_key` (`config_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表';
|
||||||
|
|
||||||
|
-- 登录日志表
|
||||||
|
CREATE TABLE IF NOT EXISTS `login_logs` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` bigint NOT NULL,
|
||||||
|
`user_account` varchar(128) DEFAULT NULL,
|
||||||
|
`action` varchar(16) NOT NULL COMMENT 'login/logout/refresh/fail',
|
||||||
|
`session_id` varchar(128) DEFAULT NULL,
|
||||||
|
`ip_address` varchar(64) DEFAULT NULL,
|
||||||
|
`error_msg` varchar(512) DEFAULT NULL,
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_user_id` (`user_id`),
|
||||||
|
KEY `idx_created_at` (`created_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录日志表';
|
||||||
|
|
||||||
|
-- 初始系统配置
|
||||||
|
INSERT INTO `system_configs` (`config_key`, `config_value`, `config_type`, `description`) VALUES
|
||||||
|
('interact_time_start', '08:00', 'string', '互动开始时间'),
|
||||||
|
('interact_time_end', '22:00', 'string', '互动结束时间'),
|
||||||
|
('interact_interval_min', '300', 'int', '互动最小间隔(秒)'),
|
||||||
|
('interact_interval_max', '1800', 'int', '互动最大间隔(秒)'),
|
||||||
|
('max_concurrent_users', '5', 'int', '并发登录用户数上限'),
|
||||||
|
('daily_token_limit', '100000', 'int', '每日全局Token上限'),
|
||||||
|
('today_token_used', '0', 'int', '今日已使用Token'),
|
||||||
|
('token_limit_reached', 'false', 'bool', 'Token限额是否已达上限'),
|
||||||
|
-- 目标平台接口配置
|
||||||
|
('news_platform_base_url', 'http://192.168.1.200:63120', 'string', '新闻业务平台接口地址'),
|
||||||
|
('auth_base_url', 'http://192.168.1.200:60040', 'string', '认证服务接口地址'),
|
||||||
|
-- 目标平台客户端参数(需根据实际情况配置)
|
||||||
|
('platform_app_id', '', 'string', '平台appId(客户端标识)'),
|
||||||
|
('platform_access_id', '', 'string', '平台accessId(客户端accessId)'),
|
||||||
|
('platform_access_secret', '', 'string', '平台accessSecret(签名密钥,可为空)'),
|
||||||
|
('platform_client_code', '', 'string', '平台clientCode(登录用)'),
|
||||||
|
('platform_org_id', '', 'string', '平台组织Id(orgId,新闻列表必填)'),
|
||||||
|
-- 互动概率
|
||||||
|
('comment_probability', '0.4', 'string', '评论触发概率'),
|
||||||
|
('reply_probability', '0.2', 'string', '回复触发概率'),
|
||||||
|
('like_probability', '0.6', 'string', '点赞触发概率'),
|
||||||
|
('collect_probability', '0.3', 'string', '收藏触发概率'),
|
||||||
|
('forward_probability', '0.15', 'string', '转发触发概率'),
|
||||||
|
('system_start_time', NULL, 'string', '系统启动时间'),
|
||||||
|
('scheduler_enabled', 'true', 'bool', '调度器是否启用')
|
||||||
|
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value);
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
0
frontend/-H
Normal file
0
frontend/-H
Normal file
0
frontend/-d
Normal file
0
frontend/-d
Normal file
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:18-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI虚拟用户新闻互动系统</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
frontend/nginx.conf
Normal file
23
frontend/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Vue Router history mode
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://ai-virtual-backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript;
|
||||||
|
}
|
||||||
1777
frontend/package-lock.json
generated
Normal file
1777
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-virtual-news-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.3.8",
|
||||||
|
"vue-router": "^4.2.5",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"element-plus": "^2.4.3",
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"echarts": "^5.4.3",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"dayjs": "^1.11.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^4.5.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
76
frontend/src/api/index.js
Normal file
76
frontend/src/api/index.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
request.interceptors.response.use(
|
||||||
|
res => {
|
||||||
|
const data = res.data
|
||||||
|
if (data.code && data.code !== 200) {
|
||||||
|
ElMessage.error(data.message || '请求失败')
|
||||||
|
return Promise.reject(new Error(data.message))
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
const msg = err.response?.data?.detail || err.response?.data?.message || err.message || '网络错误'
|
||||||
|
ElMessage.error(msg)
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
export const getDashboard = () => request.get('/dashboard')
|
||||||
|
export const getTokenTrend = (days = 30) => request.get('/dashboard/token-trend', { params: { days } })
|
||||||
|
export const getMonthlyTokenTrend = () => request.get('/dashboard/monthly-token-trend')
|
||||||
|
|
||||||
|
// Users
|
||||||
|
export const getUsers = (params) => request.get('/users', { params })
|
||||||
|
export const createUser = (data) => request.post('/users', data)
|
||||||
|
export const updateUser = (id, data) => request.put(`/users/${id}`, data)
|
||||||
|
export const deleteUser = (id) => request.delete(`/users/${id}`)
|
||||||
|
export const batchUserAction = (data) => request.post('/users/batch/action', data)
|
||||||
|
export const loginUser = (id) => request.post(`/users/${id}/login`)
|
||||||
|
export const logoutUser = (id) => request.post(`/users/${id}/logout`)
|
||||||
|
export const generatePersonality = (id) => request.post(`/users/${id}/personality/generate`)
|
||||||
|
export const updatePersonality = (id, data) => request.put(`/users/${id}/personality`, data)
|
||||||
|
export const importUsers = (formData) => request.post('/users/excel/import', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
export const downloadTemplate = () => request.get('/users/excel/template', { responseType: 'blob' })
|
||||||
|
export const exportUsers = () => request.get('/users/excel/export', { responseType: 'blob' })
|
||||||
|
export const deduplicateUsers = () => request.post('/users/deduplicate')
|
||||||
|
export const clearAllUsers = () => request.post('/users/clear-all')
|
||||||
|
export const loginAllUsers = () => request.post('/users/login-all')
|
||||||
|
export const syncAllProfiles = () => request.post('/users/sync-all-profiles')
|
||||||
|
export const cancelInteraction = (id) => request.post(`/interactions/${id}/cancel`)
|
||||||
|
export const runInteractionNow = () => request.post('/system/interaction/run-now')
|
||||||
|
|
||||||
|
// Interactions
|
||||||
|
export const getInteractions = (params) => request.get('/interactions', { params })
|
||||||
|
export const retryInteraction = (id) => request.post(`/interactions/${id}/retry`)
|
||||||
|
export const exportInteractions = (params) => request.get('/interactions/export', { params, responseType: 'blob' })
|
||||||
|
|
||||||
|
// AI Models
|
||||||
|
export const getAIModels = () => request.get('/ai-models')
|
||||||
|
export const createAIModel = (data) => request.post('/ai-models', data)
|
||||||
|
export const updateAIModel = (id, data) => request.put(`/ai-models/${id}`, data)
|
||||||
|
export const deleteAIModel = (id) => request.delete(`/ai-models/${id}`)
|
||||||
|
export const testAIModel = (data) => request.post('/ai-models/test', data)
|
||||||
|
|
||||||
|
// System
|
||||||
|
export const getSystemConfigs = () => request.get('/system/configs')
|
||||||
|
export const updateSystemConfigs = (data) => request.put('/system/configs', data)
|
||||||
|
export const toggleScheduler = (enabled) => request.post('/system/scheduler/toggle', { enabled })
|
||||||
|
export const resetAllSessions = () => request.post('/system/sessions/reset-all')
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
export const getLoginLogs = (params) => request.get('/logs/login', { params })
|
||||||
|
export const getLogFiles = () => request.get('/logs/files')
|
||||||
|
export const tailLogFile = (filename, lines = 100) => request.get(`/logs/files/${filename}/tail`, { params: { lines } })
|
||||||
|
|
||||||
|
export default request
|
||||||
|
export const uploadAvatar = (userId, formData) => request.post(`/users/${userId}/upload-avatar`, formData, { headers: { "Content-Type": "multipart/form-data" } })
|
||||||
219
frontend/src/layouts/MainLayout.vue
Normal file
219
frontend/src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<span class="logo-icon">🤖</span>
|
||||||
|
<span class="logo-text">AI互动系统</span>
|
||||||
|
</div>
|
||||||
|
<el-menu :default-active="activeMenu" router class="sidebar-menu">
|
||||||
|
<el-menu-item index="/dashboard">
|
||||||
|
<el-icon><DataAnalysis /></el-icon>
|
||||||
|
<span>数据看板</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/users">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
<span>虚拟用户</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/interactions">
|
||||||
|
<el-icon><ChatDotRound /></el-icon>
|
||||||
|
<span>互动记录</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/ai-models">
|
||||||
|
<el-icon><Cpu /></el-icon>
|
||||||
|
<span>AI模型配置</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/scheduler">
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
<span>调度设置</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/logs">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
<span>日志管理</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="system-status">
|
||||||
|
<span class="status-dot" :class="schedulerEnabled ? 'active' : 'paused'"></span>
|
||||||
|
<span>{{ schedulerEnabled ? '调度运行中' : '调度已暂停' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="version">v1.0.0</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="main-wrapper">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<span class="page-title-bar">{{ currentTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<div class="online-badge">
|
||||||
|
<span class="pulse"></span>
|
||||||
|
<span>{{ onlineUsers }} 在线</span>
|
||||||
|
</div>
|
||||||
|
<div class="token-badge">
|
||||||
|
<el-icon><Coin /></el-icon>
|
||||||
|
<span>今日剩余 {{ tokenRemaining.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" circle @click="refreshAll">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { getDashboard } from '@/api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const activeMenu = computed(() => route.path)
|
||||||
|
const currentTitle = computed(() => route.meta?.title || 'AI虚拟用户新闻互动系统')
|
||||||
|
|
||||||
|
const onlineUsers = ref(0)
|
||||||
|
const tokenRemaining = ref(0)
|
||||||
|
const schedulerEnabled = ref(true)
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
const res = await getDashboard()
|
||||||
|
onlineUsers.value = res.data?.online_users || 0
|
||||||
|
tokenRemaining.value = res.data?.token_stats?.remaining || 0
|
||||||
|
schedulerEnabled.value = res.data?.system_status?.scheduler_enabled ?? true
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
fetchStatus()
|
||||||
|
window.dispatchEvent(new CustomEvent('page-refresh'))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchStatus()
|
||||||
|
timer = setInterval(fetchStatus, 300000) // 5min
|
||||||
|
})
|
||||||
|
onUnmounted(() => clearInterval(timer))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.logo-icon { font-size: 24px; }
|
||||||
|
.logo-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
:deep(.el-menu-item) {
|
||||||
|
height: 44px;
|
||||||
|
line-height: 44px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.system-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.status-dot.active {
|
||||||
|
background: var(--color-accent-green);
|
||||||
|
box-shadow: 0 0 6px var(--color-accent-green);
|
||||||
|
animation: blink 2s infinite;
|
||||||
|
}
|
||||||
|
.status-dot.paused { background: var(--color-accent-orange); }
|
||||||
|
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||||
|
.version { font-size: 11px; color: var(--color-text-muted); opacity: 0.5; }
|
||||||
|
|
||||||
|
.main-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 56px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.page-title-bar { font-size: 15px; font-weight: 600; color: var(--color-text); }
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.online-badge, .token-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.pulse {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent-green);
|
||||||
|
animation: blink 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
21
frontend/src/main.js
Normal file
21
frontend/src/main.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './styles/global.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// Register all Element Plus icons
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, { locale: zhCn, size: 'default' })
|
||||||
|
app.mount('#app')
|
||||||
22
frontend/src/router/index.js
Normal file
22
frontend/src/router/index.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/layouts/MainLayout.vue'),
|
||||||
|
children: [
|
||||||
|
{ path: '', redirect: '/dashboard' },
|
||||||
|
{ path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '数据看板' } },
|
||||||
|
{ path: 'users', component: () => import('@/views/Users.vue'), meta: { title: '虚拟用户管理' } },
|
||||||
|
{ path: 'interactions', component: () => import('@/views/Interactions.vue'), meta: { title: '互动记录' } },
|
||||||
|
{ path: 'ai-models', component: () => import('@/views/AIModels.vue'), meta: { title: 'AI模型配置' } },
|
||||||
|
{ path: 'scheduler', component: () => import('@/views/Scheduler.vue'), meta: { title: '调度设置' } },
|
||||||
|
{ path: 'logs', component: () => import('@/views/Logs.vue'), meta: { title: '日志管理' } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
131
frontend/src/styles/global.css
Normal file
131
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
:root {
|
||||||
|
--color-bg: #f5f7fa;
|
||||||
|
--color-bg-secondary: #ffffff;
|
||||||
|
--color-bg-card: #ffffff;
|
||||||
|
--color-border: #e4e7ed;
|
||||||
|
--color-border-light: #ebeef5;
|
||||||
|
--color-text: #303133;
|
||||||
|
--color-text-muted: #909399;
|
||||||
|
--color-accent: #409eff;
|
||||||
|
--color-accent-green: #67c23a;
|
||||||
|
--color-accent-orange: #e6a23c;
|
||||||
|
--color-accent-red: #f56c6c;
|
||||||
|
--color-accent-purple: #9c7ff5;
|
||||||
|
--color-accent-yellow: #e6a23c;
|
||||||
|
--sidebar-width: 220px;
|
||||||
|
--shadow-sm: 0 1px 4px rgba(0,0,0,0.06);
|
||||||
|
--shadow-md: 0 2px 12px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, sans-serif;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
--el-table-bg-color: #ffffff !important;
|
||||||
|
--el-table-tr-bg-color: #ffffff !important;
|
||||||
|
--el-table-header-bg-color: #f5f7fa !important;
|
||||||
|
--el-table-border-color: var(--color-border) !important;
|
||||||
|
--el-table-text-color: var(--color-text) !important;
|
||||||
|
--el-table-header-text-color: #606266 !important;
|
||||||
|
--el-table-row-hover-bg-color: #f0f7ff !important;
|
||||||
|
--el-table-current-row-bg-color: #ecf5ff !important;
|
||||||
|
--el-fill-color-lighter: #f5f7fa !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
box-shadow: var(--shadow-sm) !important;
|
||||||
|
}
|
||||||
|
.el-table__body tr.el-table__row { background: #ffffff !important; }
|
||||||
|
.el-table__body tr.el-table__row--striped td { background: #fafafa !important; }
|
||||||
|
.el-table__header-wrapper { background: #f5f7fa !important; }
|
||||||
|
|
||||||
|
.el-card {
|
||||||
|
--el-card-bg-color: #ffffff !important;
|
||||||
|
--el-card-border-color: var(--color-border) !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
box-shadow: var(--shadow-sm) !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog {
|
||||||
|
--el-dialog-bg-color: #ffffff !important;
|
||||||
|
--el-dialog-border-radius: 12px !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.12) !important;
|
||||||
|
}
|
||||||
|
.el-dialog__header { border-bottom: 1px solid var(--color-border) !important; padding: 16px 20px !important; }
|
||||||
|
.el-dialog__footer { border-top: 1px solid var(--color-border) !important; padding: 12px 20px !important; }
|
||||||
|
|
||||||
|
.el-input__wrapper, .el-select__wrapper, .el-textarea__inner {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
box-shadow: 0 0 0 1px var(--color-border) inset !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
}
|
||||||
|
.el-input__wrapper:hover, .el-select__wrapper:hover { box-shadow: 0 0 0 1px #c0c4cc inset !important; }
|
||||||
|
.el-input__wrapper.is-focus, .el-select__wrapper.is-focused { box-shadow: 0 0 0 1px var(--color-accent) inset !important; }
|
||||||
|
.el-input__inner, .el-textarea__inner { color: var(--color-text) !important; background: transparent !important; }
|
||||||
|
|
||||||
|
.el-button--primary { background: var(--color-accent) !important; border-color: var(--color-accent) !important; color: #ffffff !important; }
|
||||||
|
.el-button--primary:hover { background: #66b1ff !important; border-color: #66b1ff !important; }
|
||||||
|
.el-button--success { background: var(--color-accent-green) !important; border-color: var(--color-accent-green) !important; color: #fff !important; }
|
||||||
|
.el-button--danger { background: var(--color-accent-red) !important; border-color: var(--color-accent-red) !important; color: #fff !important; }
|
||||||
|
.el-button--default { background: #ffffff !important; border-color: var(--color-border) !important; color: var(--color-text) !important; }
|
||||||
|
.el-button--default:hover { border-color: var(--color-accent) !important; color: var(--color-accent) !important; }
|
||||||
|
.el-button--warning { background: var(--color-accent-orange) !important; border-color: var(--color-accent-orange) !important; color: #fff !important; }
|
||||||
|
|
||||||
|
.el-tag { border-radius: 6px !important; font-size: 12px !important; }
|
||||||
|
.el-tag--success { background: #f0f9eb !important; border-color: #b3e19d !important; color: #67c23a !important; }
|
||||||
|
.el-tag--danger { background: #fef0f0 !important; border-color: #fbc4c4 !important; color: #f56c6c !important; }
|
||||||
|
.el-tag--warning { background: #fdf6ec !important; border-color: #f5dab1 !important; color: #e6a23c !important; }
|
||||||
|
.el-tag--info { background: #f4f4f5 !important; border-color: #d3d4d6 !important; color: #909399 !important; }
|
||||||
|
|
||||||
|
.el-pagination {
|
||||||
|
--el-pagination-bg-color: transparent !important;
|
||||||
|
--el-pagination-text-color: var(--color-text) !important;
|
||||||
|
--el-pagination-button-color: var(--color-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item__label { color: #606266 !important; font-weight: 500 !important; }
|
||||||
|
.el-radio__label { color: var(--color-text) !important; }
|
||||||
|
.el-checkbox__label { color: var(--color-text) !important; }
|
||||||
|
|
||||||
|
.el-menu { background: transparent !important; border: none !important; }
|
||||||
|
.el-menu-item, .el-sub-menu__title {
|
||||||
|
color: #606266 !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
margin: 2px 8px !important;
|
||||||
|
transition: all 0.2s !important;
|
||||||
|
}
|
||||||
|
.el-menu-item.is-active { background: #ecf5ff !important; color: var(--color-accent) !important; font-weight: 600 !important; }
|
||||||
|
.el-menu-item:hover { background: #f0f7ff !important; color: var(--color-accent) !important; }
|
||||||
|
|
||||||
|
.el-dropdown-menu { background: #ffffff !important; border: 1px solid var(--color-border) !important; box-shadow: var(--shadow-md) !important; }
|
||||||
|
.el-dropdown-menu__item { color: var(--color-text) !important; }
|
||||||
|
.el-dropdown-menu__item:hover { background: #f0f7ff !important; color: var(--color-accent) !important; }
|
||||||
|
|
||||||
|
.el-select-dropdown { background: #ffffff !important; border: 1px solid var(--color-border) !important; box-shadow: var(--shadow-md) !important; }
|
||||||
|
.el-select-dropdown__item { color: var(--color-text) !important; }
|
||||||
|
.el-select-dropdown__item.is-hovering { background: #f0f7ff !important; }
|
||||||
|
.el-select-dropdown__item.is-selected { color: var(--color-accent) !important; font-weight: 600 !important; }
|
||||||
|
|
||||||
|
.el-popover { background: #ffffff !important; border: 1px solid var(--color-border) !important; color: var(--color-text) !important; }
|
||||||
|
.el-alert { border-radius: 8px !important; }
|
||||||
|
.el-switch__core { border-color: #dcdfe6 !important; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #c0c4cc; }
|
||||||
|
|
||||||
|
.page-container { padding: 24px; height: 100%; overflow-y: auto; background: var(--color-bg); }
|
||||||
|
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||||
|
.page-title { font-size: 20px; font-weight: 600; color: var(--color-text); }
|
||||||
|
.stat-card { background: #ffffff; border: 1px solid var(--color-border); border-radius: 12px; padding: 20px; box-shadow: var(--shadow-sm); transition: box-shadow 0.2s; }
|
||||||
|
.stat-card:hover { box-shadow: var(--shadow-md); }
|
||||||
|
.stat-value { font-size: 28px; font-weight: 700; color: var(--color-accent); }
|
||||||
|
.stat-label { font-size: 13px; color: var(--color-text-muted); margin-top: 4px; }
|
||||||
|
.sidebar { background: #ffffff !important; border-right: 1px solid var(--color-border) !important; box-shadow: 1px 0 8px rgba(0,0,0,0.04) !important; }
|
||||||
239
frontend/src/views/AIModels.vue
Normal file
239
frontend/src/views/AIModels.vue
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-header">
|
||||||
|
<span class="page-title">AI模型配置</span>
|
||||||
|
<el-button type="primary" size="small" @click="openCreate">
|
||||||
|
<el-icon><Plus /></el-icon> 添加模型
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="models-grid">
|
||||||
|
<div v-for="m in models" :key="m.id" class="model-card" :class="{ 'is-default': m.is_default }">
|
||||||
|
<div class="model-head">
|
||||||
|
<div class="model-name">
|
||||||
|
<span class="provider-badge" :class="m.provider">{{ providerLabels[m.provider] || m.provider }}</span>
|
||||||
|
<span class="model-title">{{ m.model_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<el-tag v-if="m.is_default" type="success" size="small">默认</el-tag>
|
||||||
|
<el-tag v-if="!m.is_enabled" type="danger" size="small">禁用</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="model-meta">
|
||||||
|
<span>版本: {{ m.model_version || '--' }}</span>
|
||||||
|
<span>温度: {{ m.temperature }}</span>
|
||||||
|
<span>Max Tokens: {{ m.max_tokens }}</span>
|
||||||
|
<span>超时: {{ m.timeout_seconds }}s</span>
|
||||||
|
<span>API Key: {{ m.has_api_key ? '✓ 已配置' : '✗ 未配置' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="model-actions">
|
||||||
|
<el-button size="small" @click="openTest(m)">测试</el-button>
|
||||||
|
<el-button size="small" @click="openEdit(m)">编辑</el-button>
|
||||||
|
<el-button v-if="!m.is_default" size="small" type="primary" @click="setDefault(m)">设为默认</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="doDelete(m)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!models.length" class="empty-state">
|
||||||
|
<el-empty description="暂无模型配置,请添加AI模型" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Dialog -->
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editModel ? '编辑模型' : '添加AI模型'" width="540px">
|
||||||
|
<el-form :model="form" label-width="110px" :rules="rules" ref="formRef">
|
||||||
|
<el-form-item label="模型名称" prop="model_name">
|
||||||
|
<el-input v-model="form.model_name" placeholder="如: GPT-4 生产环境" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="提供商" prop="provider">
|
||||||
|
<el-select v-model="form.provider" style="width:100%" @change="onProviderChange">
|
||||||
|
<el-option v-for="(l,v) in providerLabels" :key="v" :label="l" :value="v" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="API地址">
|
||||||
|
<el-input v-model="form.api_base_url" placeholder="留空使用默认地址" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="API Key">
|
||||||
|
<el-input v-model="form.api_key" type="password" show-password :placeholder="editModel ? '不修改请留空' : '输入API Key'" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模型版本">
|
||||||
|
<el-input v-model="form.model_version" placeholder="如: gpt-4-turbo, glm-4" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="温度">
|
||||||
|
<el-input-number v-model="form.temperature" :min="0" :max="2" :step="0.1" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="Max Tokens">
|
||||||
|
<el-input-number v-model="form.max_tokens" :min="100" :max="32000" :step="100" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="超时(秒)">
|
||||||
|
<el-input-number v-model="form.timeout_seconds" :min="5" :max="300" style="width:160px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设为默认">
|
||||||
|
<el-switch v-model="form.is_default" :active-value="1" :inactive-value="0" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submit" :loading="submitting">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Test Dialog -->
|
||||||
|
<el-dialog v-model="testVisible" title="模型测试" width="560px">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="测试指令">
|
||||||
|
<el-input v-model="testPrompt" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div v-if="testResult" class="test-result">
|
||||||
|
<div class="result-meta">
|
||||||
|
<el-tag :type="testResult.success ? 'success' : 'danger'">
|
||||||
|
{{ testResult.success ? '成功' : '失败' }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="testResult.success" style="font-size:12px;color:var(--color-text-muted)">
|
||||||
|
耗时 {{ testResult.elapsed_seconds }}s · 消耗 {{ testResult.tokens }} tokens
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-content" style="white-space:pre-wrap;word-break:break-all;">
|
||||||
|
{{ testResult.success ? (testResult.content || '(响应为空)') : testResult.error }}
|
||||||
|
</div>
|
||||||
|
<div v-if="testResult.success" style="margin-top:8px;font-size:12px;color:var(--color-text-muted)">
|
||||||
|
耗时 {{ testResult.elapsed_seconds }}s · Token {{ testResult.tokens }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="testVisible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="runTest" :loading="testing">发送测试</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getAIModels, createAIModel, updateAIModel, deleteAIModel, testAIModel } from '@/api'
|
||||||
|
|
||||||
|
const models = ref([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editModel = ref(null)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
const testVisible = ref(false)
|
||||||
|
const testingModel = ref(null)
|
||||||
|
const testPrompt = ref('你好,请简单介绍一下你自己,并写一条关于"人工智能改变新闻业"的新闻评论。')
|
||||||
|
const testResult = ref(null)
|
||||||
|
const testing = ref(false)
|
||||||
|
|
||||||
|
const providerLabels = { openai: 'OpenAI', zhipu: '智谱GLM', wenxin: '文心一言', qianwen: '通义千问', local: '本地模型' }
|
||||||
|
const form = reactive({ model_name: '', provider: 'openai', api_base_url: '', api_key: '', model_version: '', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||||
|
const rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }] }
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await getAIModels()
|
||||||
|
models.value = res.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_DEFAULTS = {
|
||||||
|
openai: { api_base_url: 'https://api.openai.com/v1', model_version: 'gpt-4-turbo' },
|
||||||
|
zhipu: { api_base_url: 'https://open.bigmodel.cn/api/paas/v4', model_version: 'glm-4' },
|
||||||
|
wenxin: { api_base_url: 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat', model_version: 'ERNIE-Bot-4' },
|
||||||
|
qianwen: { api_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model_version: 'qwen-turbo' },
|
||||||
|
local: { api_base_url: 'http://127.0.0.1:11434/v1', model_version: 'qwen2' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProviderChange(provider) {
|
||||||
|
const def = PROVIDER_DEFAULTS[provider] || {}
|
||||||
|
form.api_base_url = def.api_base_url || ''
|
||||||
|
form.model_version = def.model_version || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editModel.value = null
|
||||||
|
Object.assign(form, { model_name: '', provider: 'openai', api_base_url: PROVIDER_DEFAULTS.openai.api_base_url, api_key: '', model_version: PROVIDER_DEFAULTS.openai.model_version, temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(m) {
|
||||||
|
editModel.value = m
|
||||||
|
Object.assign(form, { model_name: m.model_name, provider: m.provider, api_base_url: m.api_base_url || '', api_key: '', model_version: m.model_version || '', temperature: m.temperature, max_tokens: m.max_tokens, timeout_seconds: m.timeout_seconds, is_default: m.is_default })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
await formRef.value.validate()
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const data = { ...form }
|
||||||
|
if (editModel.value) {
|
||||||
|
if (!data.api_key) delete data.api_key
|
||||||
|
await updateAIModel(editModel.value.id, data)
|
||||||
|
} else {
|
||||||
|
await createAIModel(data)
|
||||||
|
}
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
load()
|
||||||
|
} finally { submitting.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete(m) {
|
||||||
|
await ElMessageBox.confirm(`确认删除模型 "${m.model_name}"?`, '确认', { type: 'warning' })
|
||||||
|
await deleteAIModel(m.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDefault(m) {
|
||||||
|
await updateAIModel(m.id, { is_default: 1 })
|
||||||
|
ElMessage.success(`"${m.model_name}" 已设为默认模型`)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTest(m) {
|
||||||
|
testingModel.value = m
|
||||||
|
testResult.value = null
|
||||||
|
testVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest() {
|
||||||
|
testing.value = true
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
const res = await testAIModel({ model_id: testingModel.value.id, test_prompt: testPrompt.value })
|
||||||
|
testResult.value = res.data
|
||||||
|
} finally { testing.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.models-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; }
|
||||||
|
.model-card {
|
||||||
|
background: var(--color-bg-card); border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px; padding: 18px;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.model-card.is-default { border-color: var(--color-accent-green); box-shadow: 0 0 12px rgba(63,185,80,0.15); }
|
||||||
|
.model-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||||
|
.model-name { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.provider-badge { font-size: 11px; padding: 2px 8px; border-radius: 12px; font-weight: 600; }
|
||||||
|
.provider-badge.openai { background: rgba(16,163,127,0.15); color: #10a37f; }
|
||||||
|
.provider-badge.zhipu { background: rgba(88,166,255,0.15); color: var(--color-accent); }
|
||||||
|
.provider-badge.wenxin { background: rgba(240,136,62,0.15); color: var(--color-accent-orange); }
|
||||||
|
.provider-badge.qianwen { background: rgba(210,153,34,0.15); color: var(--color-accent-yellow); }
|
||||||
|
.provider-badge.local { background: rgba(188,140,255,0.15); color: var(--color-accent-purple); }
|
||||||
|
.model-title { font-size: 15px; font-weight: 600; }
|
||||||
|
.model-meta { display: flex; flex-wrap: wrap; gap: 10px; font-size: 12px; color: var(--color-text-muted); margin-bottom: 14px; }
|
||||||
|
.model-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.test-result { margin-top: 16px; }
|
||||||
|
.result-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||||
|
.result-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
|
||||||
|
.empty-state { grid-column: 1/-1; padding: 40px; }
|
||||||
|
</style>
|
||||||
275
frontend/src/views/Dashboard.vue
Normal file
275
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container dashboard">
|
||||||
|
<!-- Top stat cards -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card" v-for="s in statCards" :key="s.label">
|
||||||
|
<div class="stat-icon" :style="{ background: s.bg }">
|
||||||
|
<el-icon :style="{ color: s.color, fontSize: '20px' }">
|
||||||
|
<component :is="s.icon" />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value" :style="{ color: s.color }">{{ s.value }}</div>
|
||||||
|
<div class="stat-label">{{ s.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Today interaction summary -->
|
||||||
|
<div class="section-row">
|
||||||
|
<el-card class="today-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>今日互动统计</span>
|
||||||
|
<el-tag type="info" size="small">{{ todayDate }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="interact-bars">
|
||||||
|
<div class="interact-item" v-for="it in interactTypes" :key="it.key">
|
||||||
|
<div class="interact-label">{{ it.label }}</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="getPercent(it.key)"
|
||||||
|
:color="it.color"
|
||||||
|
:stroke-width="10"
|
||||||
|
:show-text="false"
|
||||||
|
class="interact-bar"
|
||||||
|
/>
|
||||||
|
<div class="interact-count" :style="{ color: it.color }">
|
||||||
|
{{ todayStats[it.key] || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="system-card" shadow="never">
|
||||||
|
<template #header><span>系统运行状态</span></template>
|
||||||
|
<div class="sys-items">
|
||||||
|
<div class="sys-item">
|
||||||
|
<span class="sys-key">运行时长</span>
|
||||||
|
<span class="sys-val">{{ systemStatus.uptime || '--' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sys-item">
|
||||||
|
<span class="sys-key">在线用户</span>
|
||||||
|
<span class="sys-val accent-green">{{ onlineUsers }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sys-item">
|
||||||
|
<span class="sys-key">今日Token消耗</span>
|
||||||
|
<span class="sys-val accent-blue">{{ tokenStats.today_used?.toLocaleString() || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sys-item">
|
||||||
|
<span class="sys-key">Token剩余</span>
|
||||||
|
<span class="sys-val" :class="tokenLow ? 'accent-red' : 'accent-green'">
|
||||||
|
{{ tokenStats.remaining?.toLocaleString() || 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="sys-item">
|
||||||
|
<span class="sys-key">今日AI调用</span>
|
||||||
|
<span class="sys-val">{{ tokenStats.today_calls || 0 }} 次</span>
|
||||||
|
</div>
|
||||||
|
<div class="sys-item">
|
||||||
|
<span class="sys-key">调度器状态</span>
|
||||||
|
<el-tag :type="systemStatus.scheduler_enabled ? 'success' : 'warning'" size="small">
|
||||||
|
{{ systemStatus.scheduler_enabled ? '运行中' : '已暂停' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="tokenPercent"
|
||||||
|
:color="tokenLow ? '#f85149' : '#58a6ff'"
|
||||||
|
:stroke-width="8"
|
||||||
|
style="margin-top: 16px;"
|
||||||
|
>
|
||||||
|
<span style="font-size:11px;color:var(--color-text-muted)">Token使用率 {{ tokenPercent }}%</span>
|
||||||
|
</el-progress>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts row -->
|
||||||
|
<div class="charts-row">
|
||||||
|
<el-card class="chart-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>近30天 Token消耗趋势</span>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<el-button size="small" @click="loadTokenTrend(30)">30天</el-button>
|
||||||
|
<el-button size="small" @click="loadTokenTrend(7)">7天</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="dailyChart" class="chart-box"></div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="chart-card" shadow="never">
|
||||||
|
<template #header><span>近12个月 Token消耗</span></template>
|
||||||
|
<div ref="monthlyChart" class="chart-box"></div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { getDashboard, getTokenTrend, getMonthlyTokenTrend } from '@/api'
|
||||||
|
|
||||||
|
const todayDate = dayjs().format('YYYY-MM-DD')
|
||||||
|
|
||||||
|
// data
|
||||||
|
const userStats = ref({})
|
||||||
|
const todayStats = ref({})
|
||||||
|
const tokenStats = ref({})
|
||||||
|
const systemStatus = ref({})
|
||||||
|
const onlineUsers = ref(0)
|
||||||
|
|
||||||
|
const dailyChart = ref(null)
|
||||||
|
const monthlyChart = ref(null)
|
||||||
|
let dailyChartInst = null
|
||||||
|
let monthlyChartInst = null
|
||||||
|
|
||||||
|
const interactTypes = [
|
||||||
|
{ key: 'comment', label: '评论', color: '#58a6ff' },
|
||||||
|
{ key: 'reply', label: '回复', color: '#bc8cff' },
|
||||||
|
{ key: 'like', label: '点赞', color: '#3fb950' },
|
||||||
|
{ key: 'collect', label: '收藏', color: '#f0883e' },
|
||||||
|
{ key: 'forward', label: '转发', color: '#d29922' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const maxInteract = computed(() => {
|
||||||
|
const vals = interactTypes.map(t => todayStats.value[t.key] || 0)
|
||||||
|
return Math.max(...vals, 1)
|
||||||
|
})
|
||||||
|
const getPercent = (key) => Math.round(((todayStats.value[key] || 0) / maxInteract.value) * 100)
|
||||||
|
const tokenPercent = computed(() => {
|
||||||
|
const used = tokenStats.value.today_used || 0
|
||||||
|
const limit = tokenStats.value.daily_limit || 1
|
||||||
|
return Math.min(Math.round((used / limit) * 100), 100)
|
||||||
|
})
|
||||||
|
const tokenLow = computed(() => tokenPercent.value > 80)
|
||||||
|
|
||||||
|
const statCards = computed(() => [
|
||||||
|
{ label: '用户总数', value: userStats.value.total || 0, icon: 'User', color: '#58a6ff', bg: 'rgba(88,166,255,0.1)' },
|
||||||
|
{ label: '正常启用', value: userStats.value.normal || 0, icon: 'CircleCheck', color: '#3fb950', bg: 'rgba(63,185,80,0.1)' },
|
||||||
|
{ label: '当前在线', value: onlineUsers.value, icon: 'Connection', color: '#bc8cff', bg: 'rgba(188,140,255,0.1)' },
|
||||||
|
{ label: '今日互动', value: todayStats.value.total || 0, icon: 'ChatDotRound', color: '#f0883e', bg: 'rgba(240,136,62,0.1)' },
|
||||||
|
{ label: '登录失效', value: userStats.value.abnormal || 0, icon: 'Warning', color: '#d29922', bg: 'rgba(210,153,34,0.1)' },
|
||||||
|
{ label: '封禁用户', value: userStats.value.banned || 0, icon: 'CircleClose', color: '#f85149', bg: 'rgba(248,81,73,0.1)' },
|
||||||
|
])
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
try {
|
||||||
|
const res = await getDashboard()
|
||||||
|
const d = res.data
|
||||||
|
userStats.value = d.user_stats || {}
|
||||||
|
todayStats.value = d.today_interactions || {}
|
||||||
|
tokenStats.value = d.token_stats || {}
|
||||||
|
systemStatus.value = d.system_status || {}
|
||||||
|
onlineUsers.value = d.online_users || 0
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTokenTrend(days = 30) {
|
||||||
|
try {
|
||||||
|
const res = await getTokenTrend(days)
|
||||||
|
const data = res.data || []
|
||||||
|
if (!dailyChartInst) return
|
||||||
|
dailyChartInst.setOption({
|
||||||
|
tooltip: { trigger: 'axis', backgroundColor: '#1c2128', borderColor: '#30363d', textStyle: { color: '#e6edf3' } },
|
||||||
|
grid: { left: 50, right: 20, top: 20, bottom: 40 },
|
||||||
|
xAxis: { type: 'category', data: data.map(d => d.date.slice(5)), axisLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||||
|
yAxis: { type: 'value', axisLine: { lineStyle: { color: '#30363d' } }, splitLine: { lineStyle: { color: '#21262d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||||
|
series: [{
|
||||||
|
type: 'line', data: data.map(d => d.tokens),
|
||||||
|
smooth: true, symbol: 'none',
|
||||||
|
lineStyle: { color: '#58a6ff', width: 2 },
|
||||||
|
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(88,166,255,0.3)' }, { offset: 1, color: 'rgba(88,166,255,0)' }] } },
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMonthlyTrend() {
|
||||||
|
try {
|
||||||
|
const res = await getMonthlyTokenTrend()
|
||||||
|
const data = res.data || []
|
||||||
|
if (!monthlyChartInst) return
|
||||||
|
monthlyChartInst.setOption({
|
||||||
|
tooltip: { trigger: 'axis', backgroundColor: '#1c2128', borderColor: '#30363d', textStyle: { color: '#e6edf3' } },
|
||||||
|
grid: { left: 55, right: 20, top: 20, bottom: 40 },
|
||||||
|
xAxis: { type: 'category', data: data.map(d => d.month), axisLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||||
|
yAxis: { type: 'value', axisLine: { lineStyle: { color: '#30363d' } }, splitLine: { lineStyle: { color: '#21262d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||||
|
series: [{
|
||||||
|
type: 'bar', data: data.map(d => d.tokens),
|
||||||
|
itemStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: '#bc8cff' }, { offset: 1, color: 'rgba(188,140,255,0.2)' }] }, borderRadius: [4, 4, 0, 0] },
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResize = () => { dailyChartInst?.resize(); monthlyChartInst?.resize() }
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadDashboard()
|
||||||
|
dailyChartInst = echarts.init(dailyChart.value, null, { renderer: 'svg' })
|
||||||
|
monthlyChartInst = echarts.init(monthlyChart.value, null, { renderer: 'svg' })
|
||||||
|
await loadTokenTrend(30)
|
||||||
|
await loadMonthlyTrend()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
window.addEventListener('page-refresh', loadDashboard)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
window.removeEventListener('page-refresh', loadDashboard)
|
||||||
|
dailyChartInst?.dispose()
|
||||||
|
monthlyChartInst?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1400px) { .stats-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.stat-card:hover { border-color: var(--color-accent); }
|
||||||
|
.stat-icon { width: 44px; height: 44px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.stat-value { font-size: 24px; font-weight: 700; line-height: 1; }
|
||||||
|
.stat-label { font-size: 12px; color: var(--color-text-muted); margin-top: 4px; }
|
||||||
|
|
||||||
|
.section-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
:deep(.el-card) { background: var(--color-bg-card) !important; border-color: var(--color-border) !important; }
|
||||||
|
:deep(.el-card__header) { border-bottom: 1px solid var(--color-border); padding: 14px 18px; font-size: 14px; font-weight: 600; color: var(--color-text); }
|
||||||
|
:deep(.el-card__body) { padding: 18px; }
|
||||||
|
.card-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
|
||||||
|
.interact-bars { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.interact-item { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.interact-label { width: 32px; font-size: 12px; color: var(--color-text-muted); flex-shrink: 0; }
|
||||||
|
.interact-bar { flex: 1; }
|
||||||
|
.interact-count { width: 36px; text-align: right; font-size: 13px; font-weight: 600; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.sys-items { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.sys-item { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.sys-key { font-size: 11px; color: var(--color-text-muted); }
|
||||||
|
.sys-val { font-size: 15px; font-weight: 600; color: var(--color-text); }
|
||||||
|
.accent-green { color: var(--color-accent-green) !important; }
|
||||||
|
.accent-blue { color: var(--color-accent) !important; }
|
||||||
|
.accent-red { color: var(--color-accent-red) !important; }
|
||||||
|
|
||||||
|
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.chart-box { height: 220px; }
|
||||||
|
</style>
|
||||||
165
frontend/src/views/Interactions.vue
Normal file
165
frontend/src/views/Interactions.vue
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-header">
|
||||||
|
<span class="page-title">互动记录</span>
|
||||||
|
<el-button size="small" @click="handleExport">
|
||||||
|
<el-icon><Download /></el-icon> 导出
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="filters.keyword" placeholder="搜索用户/文章" clearable @change="load" style="width:200px">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-select v-model="filters.interact_type" placeholder="互动类型" clearable @change="load" style="width:120px">
|
||||||
|
<el-option v-for="(l,v) in typeMap" :key="v" :label="l" :value="v" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.status" placeholder="状态" clearable @change="load" style="width:110px">
|
||||||
|
<el-option label="执行中" :value="0"/><el-option label="成功" :value="1"/><el-option label="失败" :value="2"/>
|
||||||
|
</el-select>
|
||||||
|
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" @change="onDateChange" style="width:240px" />
|
||||||
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="records" v-loading="loading" stripe class="data-table">
|
||||||
|
<el-table-column label="用户" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div style="font-size:13px;font-weight:600">{{ row.user_nickname }}</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-muted)">{{ row.user_account }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="文章" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-size:13px">{{ row.article_title || '--' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类型" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="typeTagType[row.interact_type]" size="small">{{ row.interact_type_label }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="内容" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.content || '--' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Token" width="75" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-size:12px;color:var(--color-accent)">{{ row.token_consumed || 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType[row.status]" size="small">{{ row.status_label }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="执行时间" width="145">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.executed_at ? new Date(row.executed_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}) : '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 1 && row.interact_type !== 'forward' && row.interact_type !== 'read'"
|
||||||
|
size="small" type="danger" plain
|
||||||
|
:loading="cancellingId === row.id"
|
||||||
|
@click="handleCancel(row)">取消</el-button>
|
||||||
|
<el-button v-if="row.status === 2 && row.retry_count < 3" size="small" type="warning" @click="retry(row)">重试</el-button>
|
||||||
|
<el-tooltip v-else-if="row.status === 2" content="已超过最大重试次数" placement="top">
|
||||||
|
<el-button size="small" disabled>重试</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:total="total" :page-sizes="[20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@change="load" style="margin-top:16px;justify-content:flex-end;display:flex"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getInteractions, retryInteraction, exportInteractions, cancelInteraction } from '@/api'
|
||||||
|
|
||||||
|
const records = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const dateRange = ref(null)
|
||||||
|
const filters = reactive({ keyword: '', interact_type: null, status: null, start_date: '', end_date: '' })
|
||||||
|
|
||||||
|
const typeMap = { comment: '评论', reply: '回复', like: '点赞', collect: '收藏', forward: '转发' }
|
||||||
|
const typeTagType = { comment: '', reply: 'info', like: 'success', collect: 'warning', forward: 'danger' }
|
||||||
|
const statusTagType = { 0: 'info', 1: 'success', 2: 'danger' }
|
||||||
|
|
||||||
|
function onDateChange(v) {
|
||||||
|
filters.start_date = v?.[0] || ''; filters.end_date = v?.[1] || ''
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getInteractions({ page: page.value, page_size: pageSize.value, ...filters })
|
||||||
|
records.value = res.data?.items || []
|
||||||
|
total.value = res.data?.total || 0
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
Object.assign(filters, { keyword: '', interact_type: null, status: null, start_date: '', end_date: '' })
|
||||||
|
dateRange.value = null; load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancellingId = ref(null)
|
||||||
|
|
||||||
|
async function handleCancel(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认取消「${row.interact_type === 'comment' ? '评论' : row.interact_type === 'like' ? '点赞' : '收藏'}」互动?`,
|
||||||
|
'取消互动', { confirmButtonText: '确认取消', cancelButtonText: '再想想', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
|
||||||
|
cancellingId.value = row.id
|
||||||
|
try {
|
||||||
|
const res = await cancelInteraction(row.id)
|
||||||
|
if (res.code === 200 || res.code === 0) {
|
||||||
|
ElMessage.success('取消成功')
|
||||||
|
await loadInteractions()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '取消失败')
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
ElMessage.error('取消失败:' + (e.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
cancellingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retry(row) {
|
||||||
|
await retryInteraction(row.id)
|
||||||
|
ElMessage.success('重试已触发')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
const res = await exportInteractions(filters)
|
||||||
|
const url = URL.createObjectURL(new Blob([res]))
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = 'interactions.xlsx'; a.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { load(); window.addEventListener('page-refresh', load) })
|
||||||
|
onUnmounted(() => window.removeEventListener('page-refresh', load))
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.data-table { border-radius: 8px; overflow: hidden; }
|
||||||
|
</style>
|
||||||
187
frontend/src/views/Logs.vue
Normal file
187
frontend/src/views/Logs.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-header">
|
||||||
|
<span class="page-title">日志管理</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="log-tabs">
|
||||||
|
<!-- Login logs -->
|
||||||
|
<el-tab-pane label="登录日志" name="login">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select v-model="loginFilter.action" placeholder="操作类型" clearable @change="loadLoginLogs" style="width:130px">
|
||||||
|
<el-option label="登录" value="login"/>
|
||||||
|
<el-option label="登出" value="logout"/>
|
||||||
|
<el-option label="刷新" value="refresh"/>
|
||||||
|
<el-option label="失败" value="fail"/>
|
||||||
|
</el-select>
|
||||||
|
<el-button @click="loadLoginLogs"><el-icon><Refresh /></el-icon></el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="loginLogs" v-loading="loginLoading" stripe class="data-table">
|
||||||
|
<el-table-column label="用户账号" prop="user_account" width="180" />
|
||||||
|
<el-table-column label="操作" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="actionType[row.action] || 'info'" size="small">{{ actionLabel[row.action] || row.action }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="会话ID" prop="session_id" show-overflow-tooltip />
|
||||||
|
<el-table-column label="失败原因" prop="error_msg" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="color:var(--color-accent-red);font-size:12px">{{ row.error_msg || '--' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时间" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.created_at?.slice(0,16) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination v-model:current-page="loginPage" :page-size="50" :total="loginTotal"
|
||||||
|
layout="total, prev, pager, next" @current-change="loadLoginLogs" style="margin-top:12px;display:flex;justify-content:flex-end" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- Log files -->
|
||||||
|
<el-tab-pane label="日志文件" name="files">
|
||||||
|
<div style="display:flex;gap:16px;height:600px">
|
||||||
|
<!-- File list -->
|
||||||
|
<div class="file-list">
|
||||||
|
<div class="file-list-header">日志文件</div>
|
||||||
|
<div
|
||||||
|
v-for="f in logFiles" :key="f.name"
|
||||||
|
class="file-item" :class="{ active: selectedFile === f.name }"
|
||||||
|
@click="openFile(f.name)"
|
||||||
|
>
|
||||||
|
<el-icon style="flex-shrink:0"><Document /></el-icon>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">{{ f.name }}</div>
|
||||||
|
<div class="file-size">{{ f.size_kb }} KB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!logFiles.length" style="padding:20px;color:var(--color-text-muted);font-size:13px;text-align:center">暂无日志文件</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File content -->
|
||||||
|
<div class="file-content" v-if="selectedFile">
|
||||||
|
<div class="file-toolbar">
|
||||||
|
<span style="font-size:13px;font-weight:600">{{ selectedFile }}</span>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<el-select v-model="tailLines" style="width:100px" @change="loadFileContent">
|
||||||
|
<el-option label="最后100行" :value="100"/>
|
||||||
|
<el-option label="最后500行" :value="500"/>
|
||||||
|
<el-option label="最后1000行" :value="1000"/>
|
||||||
|
</el-select>
|
||||||
|
<el-button size="small" @click="loadFileContent"><el-icon><Refresh /></el-icon></el-button>
|
||||||
|
<el-button size="small" type="primary" @click="downloadFile">下载</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="log-content" ref="logContentRef">
|
||||||
|
<div v-for="(line, i) in fileLines" :key="i" class="log-line" :class="getLineClass(line)">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="file-empty">
|
||||||
|
<el-empty description="选择左侧日志文件查看内容" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, nextTick, onMounted } from 'vue'
|
||||||
|
import { getLoginLogs, getLogFiles, tailLogFile } from '@/api'
|
||||||
|
|
||||||
|
const activeTab = ref('login')
|
||||||
|
const loginLogs = ref([])
|
||||||
|
const loginLoading = ref(false)
|
||||||
|
const loginPage = ref(1)
|
||||||
|
const loginTotal = ref(0)
|
||||||
|
const loginFilter = reactive({ action: '' })
|
||||||
|
|
||||||
|
const logFiles = ref([])
|
||||||
|
const selectedFile = ref('')
|
||||||
|
const fileLines = ref([])
|
||||||
|
const tailLines = ref(100)
|
||||||
|
const logContentRef = ref()
|
||||||
|
|
||||||
|
const actionLabel = { login: '登录', logout: '登出', refresh: '刷新', fail: '失败' }
|
||||||
|
const actionType = { login: 'success', logout: 'info', refresh: 'warning', fail: 'danger' }
|
||||||
|
|
||||||
|
function getLineClass(line) {
|
||||||
|
if (line.includes('ERROR') || line.includes('error')) return 'line-error'
|
||||||
|
if (line.includes('WARNING') || line.includes('WARN')) return 'line-warn'
|
||||||
|
if (line.includes('INFO')) return 'line-info'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLoginLogs() {
|
||||||
|
loginLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getLoginLogs({ page: loginPage.value, page_size: 50, action: loginFilter.action || undefined })
|
||||||
|
loginLogs.value = res.data?.items || []
|
||||||
|
loginTotal.value = res.data?.total || 0
|
||||||
|
} finally { loginLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogFiles() {
|
||||||
|
const res = await getLogFiles()
|
||||||
|
logFiles.value = res.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFile(name) {
|
||||||
|
selectedFile.value = name
|
||||||
|
await loadFileContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFileContent() {
|
||||||
|
if (!selectedFile.value) return
|
||||||
|
const res = await tailLogFile(selectedFile.value, tailLines.value)
|
||||||
|
fileLines.value = (res.data?.lines || []).map(l => l.replace(/\n$/, ''))
|
||||||
|
await nextTick()
|
||||||
|
if (logContentRef.value) logContentRef.value.scrollTop = logContentRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile() {
|
||||||
|
window.open(`/api/logs/files/${selectedFile.value}/download`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadLoginLogs(); loadLogFiles() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||||
|
.data-table { border-radius: 8px; }
|
||||||
|
:deep(.log-tabs .el-tabs__header) { margin-bottom: 16px; }
|
||||||
|
:deep(.el-tabs__item) { color: var(--color-text-muted) !important; }
|
||||||
|
:deep(.el-tabs__item.is-active) { color: var(--color-accent) !important; }
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
width: 220px; flex-shrink: 0;
|
||||||
|
background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.file-list-header { padding: 12px 14px; font-size: 12px; font-weight: 600; color: var(--color-text-muted); border-bottom: 1px solid var(--color-border); }
|
||||||
|
.file-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 14px; cursor: pointer; transition: background 0.15s;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
.file-item:hover { background: rgba(88,166,255,0.06); }
|
||||||
|
.file-item.active { background: rgba(88,166,255,0.12); color: var(--color-accent); }
|
||||||
|
.file-name { font-size: 12px; font-weight: 500; }
|
||||||
|
.file-size { font-size: 11px; color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.file-content {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.file-toolbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 14px; border-bottom: 1px solid var(--color-border); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.log-content { flex: 1; overflow-y: auto; padding: 10px 14px; font-family: 'Consolas','Monaco',monospace; font-size: 12px; line-height: 1.6; }
|
||||||
|
.log-line { padding: 1px 0; color: var(--color-text-muted); white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.line-error { color: var(--color-accent-red); }
|
||||||
|
.line-warn { color: var(--color-accent-orange); }
|
||||||
|
.line-info { color: var(--color-text); }
|
||||||
|
.file-empty { flex: 1; display: flex; align-items: center; justify-content: center; background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px; }
|
||||||
|
</style>
|
||||||
244
frontend/src/views/Scheduler.vue
Normal file
244
frontend/src/views/Scheduler.vue
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-header">
|
||||||
|
<span class="page-title">调度设置</span>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center">
|
||||||
|
<el-tag :type="schedulerEnabled ? 'success' : 'warning'" size="default">
|
||||||
|
{{ schedulerEnabled ? '调度器运行中' : '调度器已暂停' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button :type="schedulerEnabled ? 'warning' : 'success'" @click="toggleScheduler">
|
||||||
|
{{ schedulerEnabled ? '暂停调度' : '启动调度' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" @click="runNow" :loading="runningNow">
|
||||||
|
⚡ 立即执行一次互动
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" plain @click="resetSessions">重置所有会话</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form :model="configs" label-width="160px" v-loading="loading" class="settings-form">
|
||||||
|
<!-- Time settings -->
|
||||||
|
<el-card shadow="never" class="settings-card">
|
||||||
|
<template #header><span>互动时间配置</span></template>
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="互动开始时间">
|
||||||
|
<el-time-select v-model="configs.interact_time_start" :start="'00:00'" :end="'23:30'" :step="'00:30'" style="width:140px" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="互动结束时间">
|
||||||
|
<el-time-select v-model="configs.interact_time_end" :start="'00:30'" :end="'23:59'" :step="'00:30'" style="width:140px" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="最小互动间隔(秒)">
|
||||||
|
<el-input-number v-model.number="configs.interact_interval_min" :min="60" :max="3600" :step="60" style="width:160px" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="最大互动间隔(秒)">
|
||||||
|
<el-input-number v-model.number="configs.interact_interval_max" :min="120" :max="7200" :step="60" style="width:160px" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="并发登录用户上限">
|
||||||
|
<el-input-number v-model.number="configs.max_concurrent_users" :min="0" :max="200" style="width:160px" />
|
||||||
|
<span class="hint">同时进行互动的最大用户数,<b style="color:#58a6ff">0 = 无上限</b></span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Token quota -->
|
||||||
|
<el-card shadow="never" class="settings-card">
|
||||||
|
<template #header><span>Token配额管控</span></template>
|
||||||
|
<el-form-item label="每日Token上限">
|
||||||
|
<el-input-number v-model.number="configs.daily_token_limit" :min="1000" :max="10000000" :step="10000" style="width:200px" />
|
||||||
|
<span class="hint">超过上限自动暂停AI任务,次日零点重置</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="当前使用情况">
|
||||||
|
<el-progress :percentage="tokenPercent" :color="tokenPercent > 80 ? '#f85149' : '#58a6ff'" style="width:300px" />
|
||||||
|
<span class="hint">{{ todayUsed.toLocaleString() }} / {{ (configs.daily_token_limit || 0).toLocaleString() }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Interaction probabilities -->
|
||||||
|
<el-card shadow="never" class="settings-card">
|
||||||
|
<template #header><span>互动概率配置(0-1之间)</span></template>
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="8" v-for="item in probItems" :key="item.key">
|
||||||
|
<el-form-item :label="item.label">
|
||||||
|
<el-input-number
|
||||||
|
v-model.number="configs[item.key]"
|
||||||
|
:min="0" :max="1" :step="0.05" :precision="2"
|
||||||
|
style="width:130px"
|
||||||
|
/>
|
||||||
|
<span class="hint">{{ (configs[item.key] * 100).toFixed(0) }}%</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- News platform config -->
|
||||||
|
<el-card shadow="never" class="settings-card">
|
||||||
|
<template #header>
|
||||||
|
<span>目标平台接口配置</span>
|
||||||
|
<el-tag type="warning" size="small" style="margin-left:8px">必须正确配置才能登录互动</el-tag>
|
||||||
|
</template>
|
||||||
|
<el-form-item label="业务接口地址">
|
||||||
|
<el-input v-model="configs.news_platform_base_url" placeholder="http://192.168.1.200:63120" style="width:360px" />
|
||||||
|
<span class="hint">新闻/评论/点赞等业务接口</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="认证服务地址">
|
||||||
|
<el-input v-model="configs.auth_base_url" placeholder="http://192.168.1.200:60040" style="width:360px" />
|
||||||
|
<span class="hint">登录接口所在服务地址</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="平台 appId">
|
||||||
|
<el-input v-model="configs.platform_app_id" placeholder="客户端appId标识" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="平台 accessId">
|
||||||
|
<el-input v-model="configs.platform_access_id" placeholder="客户端accessId" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="签名密钥">
|
||||||
|
<el-input v-model="configs.platform_access_secret" type="password" show-password placeholder="accessSecret(可为空)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="clientCode">
|
||||||
|
<el-input v-model="configs.platform_client_code" placeholder="登录用clientCode" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="组织Id (orgId)">
|
||||||
|
<el-input v-model="configs.platform_org_id" placeholder="留空则登录后自动获取" style="width:240px" />
|
||||||
|
<span class="hint">可留空,系统登录后自动从用户信息接口获取并保存</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div style="padding-left:160px;margin-top:8px">
|
||||||
|
<el-button type="primary" @click="save" :loading="saving" size="large">
|
||||||
|
<el-icon><Check /></el-icon> 保存所有配置
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getSystemConfigs, updateSystemConfigs, toggleScheduler as apiToggle, resetAllSessions, getDashboard } from '@/api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const schedulerEnabled = ref(true)
|
||||||
|
const todayUsed = ref(0)
|
||||||
|
|
||||||
|
const configs = reactive({
|
||||||
|
interact_time_start: '08:00',
|
||||||
|
interact_time_end: '22:00',
|
||||||
|
interact_interval_min: 300,
|
||||||
|
interact_interval_max: 1800,
|
||||||
|
max_concurrent_users: 5,
|
||||||
|
daily_token_limit: 100000,
|
||||||
|
comment_probability: 0.4,
|
||||||
|
reply_probability: 0.2,
|
||||||
|
like_probability: 0.6,
|
||||||
|
collect_probability: 0.3,
|
||||||
|
forward_probability: 0.15,
|
||||||
|
// 目标平台配置
|
||||||
|
news_platform_base_url: 'http://192.168.1.200:63120',
|
||||||
|
auth_base_url: 'http://192.168.1.200:60040',
|
||||||
|
platform_app_id: '',
|
||||||
|
platform_access_id: '',
|
||||||
|
platform_access_secret: '',
|
||||||
|
platform_client_code: '',
|
||||||
|
platform_org_id: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const probItems = [
|
||||||
|
{ key: 'comment_probability', label: '评论概率' },
|
||||||
|
{ key: 'reply_probability', label: '回复概率' },
|
||||||
|
{ key: 'like_probability', label: '点赞概率' },
|
||||||
|
{ key: 'collect_probability', label: '收藏概率' },
|
||||||
|
{ key: 'forward_probability', label: '转发概率' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tokenPercent = computed(() => {
|
||||||
|
const limit = configs.daily_token_limit || 1
|
||||||
|
return Math.min(Math.round((todayUsed.value / limit) * 100), 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [cfgRes, dashRes] = await Promise.all([getSystemConfigs(), getDashboard()])
|
||||||
|
const cfg = cfgRes.data || {}
|
||||||
|
Object.keys(configs).forEach(k => {
|
||||||
|
if (cfg[k]) {
|
||||||
|
const v = cfg[k].value
|
||||||
|
const t = cfg[k].type
|
||||||
|
configs[k] = t === 'int' ? parseInt(v) : (t === 'string' || !t) ? v : parseFloat(v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
schedulerEnabled.value = cfg['scheduler_enabled']?.value === 'true'
|
||||||
|
todayUsed.value = dashRes.data?.token_stats?.today_used || 0
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {}
|
||||||
|
Object.keys(configs).forEach(k => { payload[k] = String(configs[k]) })
|
||||||
|
await updateSystemConfigs(payload)
|
||||||
|
ElMessage.success('配置已保存')
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNow() {
|
||||||
|
runningNow.value = true
|
||||||
|
try {
|
||||||
|
const res = await runInteractionNow()
|
||||||
|
const d = res.data || {}
|
||||||
|
ElMessage.success(`已触发互动:${d.triggered || 0} 个用户`)
|
||||||
|
} catch(e) {
|
||||||
|
ElMessage.error('触发失败:' + (e.message || '未知'))
|
||||||
|
} finally {
|
||||||
|
runningNow.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleScheduler() {
|
||||||
|
const newVal = !schedulerEnabled.value
|
||||||
|
await apiToggle(newVal)
|
||||||
|
schedulerEnabled.value = newVal
|
||||||
|
ElMessage.success(newVal ? '调度器已启动' : '调度器已暂停')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetSessions() {
|
||||||
|
await ElMessageBox.confirm('将重置所有用户的登录会话,用户需要重新登录才能互动,是否继续?', '警告', { type: 'warning' })
|
||||||
|
await resetAllSessions()
|
||||||
|
ElMessage.success('所有会话已重置')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-form { max-width: 900px; }
|
||||||
|
.settings-card { margin-bottom: 16px; }
|
||||||
|
:deep(.el-card__header) { border-bottom: 1px solid var(--color-border); padding: 14px 18px; font-size: 14px; font-weight: 600; color: var(--color-text); }
|
||||||
|
:deep(.el-card__body) { padding: 20px 18px 12px; }
|
||||||
|
:deep(.el-form-item) { margin-bottom: 16px; }
|
||||||
|
.hint { font-size: 11px; color: var(--color-text-muted); margin-left: 8px; }
|
||||||
|
</style>
|
||||||
593
frontend/src/views/Users.vue
Normal file
593
frontend/src/views/Users.vue
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
|
<span class="page-title">虚拟用户管理</span>
|
||||||
|
<el-tag type="info">共 {{ total }} 个用户</el-tag>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<el-button @click="downloadTemplate" size="small">
|
||||||
|
<el-icon><Download /></el-icon> 下载模板
|
||||||
|
</el-button>
|
||||||
|
<el-upload :show-file-list="false" accept=".xlsx,.xls" :before-upload="handleImport">
|
||||||
|
<el-tooltip content="仅账号和密码为必填,昵称为空时自动生成" placement="top">
|
||||||
|
<el-button size="small" type="warning">
|
||||||
|
<el-icon><Upload /></el-icon> 批量导入
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</el-upload>
|
||||||
|
<el-button size="small" @click="handleExport">
|
||||||
|
<el-icon><Download /></el-icon> 导出
|
||||||
|
</el-button>
|
||||||
|
<el-popconfirm title="将删除重复账号,只保留最早导入的一条,确认?" @confirm="handleDeduplicate" confirm-button-text="确认去重" cancel-button-text="取消">
|
||||||
|
<template #reference>
|
||||||
|
<el-button size="small" type="warning">
|
||||||
|
<el-icon><CopyDocument /></el-icon> 去重
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
<el-popconfirm title="将对所有未登录/登录失效的用户执行登录,确认?" @confirm="handleLoginAll" confirm-button-text="确认" cancel-button-text="取消">
|
||||||
|
<template #reference>
|
||||||
|
<el-button size="small" type="success" :loading="loginAllLoading">
|
||||||
|
<el-icon><Connection /></el-icon> 一键登录全部
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
<el-tooltip content="从目标平台同步所有已登录用户的昵称/真实姓名/性别/头像" placement="top">
|
||||||
|
<el-button size="small" type="info" :loading="syncLoading" @click="handleSyncAll">
|
||||||
|
<el-icon><Refresh /></el-icon> 同步用户信息
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-button type="primary" size="small" @click="openCreateDialog">
|
||||||
|
<el-icon><Plus /></el-icon> 新增用户
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="filters.keyword" placeholder="搜索昵称/账号" clearable @change="loadUsers" style="width:200px">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-select v-model="filters.status" placeholder="用户状态" clearable @change="loadUsers" style="width:130px">
|
||||||
|
<el-option v-for="(label,val) in statusMap" :key="val" :label="label" :value="Number(val)" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.is_enabled" placeholder="启用状态" clearable @change="loadUsers" style="width:120px">
|
||||||
|
<el-option label="已启用" :value="1" /><el-option label="已禁用" :value="0" />
|
||||||
|
</el-select>
|
||||||
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Batch actions -->
|
||||||
|
<div v-if="selectedIds.length" class="batch-bar">
|
||||||
|
<span class="batch-info">已选 {{ selectedIds.length }} 项</span>
|
||||||
|
<el-button size="small" type="success" @click="batchAction('enable')">批量启用</el-button>
|
||||||
|
<el-button size="small" type="warning" @click="batchAction('disable')">批量禁用</el-button>
|
||||||
|
<el-button size="small" @click="batchAction('logout')">批量登出</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="batchAction('delete')">批量删除</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<el-table
|
||||||
|
:data="users" v-loading="loading" :row-class-name="rowClassName"
|
||||||
|
@selection-change="selectedIds = $event.map(r => r.id)"
|
||||||
|
class="data-table"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="48" />
|
||||||
|
<el-table-column label="用户" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
|
<el-avatar :size="36" :src="row.avatar_url || ''" style="background:#30363d;flex-shrink:0">
|
||||||
|
{{ row.nickname?.[0] }}
|
||||||
|
</el-avatar>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;font-size:13px">{{ row.nickname }}</div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-muted)">{{ row.account }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusType[row.status]" size="small">{{ row.status_label }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="活跃度" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="activityType[row.activity_level]" size="small" effect="plain">{{ row.activity_label }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="人格" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.personality" style="display:flex;gap:4px;flex-wrap:wrap">
|
||||||
|
<el-tag size="small" effect="plain">{{ row.personality.character_type }}</el-tag>
|
||||||
|
<el-tag size="small" effect="plain" type="warning">{{ row.personality.language_style }}</el-tag>
|
||||||
|
<el-tag v-for="t in (row.personality.interest_tags||[]).slice(0,2)" :key="t" size="small" effect="plain" type="info">{{ t }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<span v-else style="color:var(--color-text-muted);font-size:12px">未生成</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="今日评论" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="row.today_comment_count >= row.daily_comment_limit ? 'color:var(--color-accent-red)' : ''">
|
||||||
|
{{ row.today_comment_count }}/{{ row.daily_comment_limit }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="累计互动" width="90" align="center" prop="total_interactions" />
|
||||||
|
<el-table-column label="最后互动" width="145">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.last_interact_at ? new Date(row.last_interact_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}) : '--' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="启用" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch :model-value="row.is_enabled === 1" @change="toggleEnabled(row)" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="220" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div style="display:flex;gap:6px;align-items:center;flex-wrap:nowrap;">
|
||||||
|
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="success" v-if="row.status !== 2" @click="doLogin(row)">登录</el-button>
|
||||||
|
<el-button size="small" type="warning" v-else @click="doLogout(row)">登出</el-button>
|
||||||
|
<el-dropdown size="small" trigger="click">
|
||||||
|
<el-button size="small">更多<el-icon style="margin-left:2px"><ArrowDown /></el-icon></el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item @click="openPersonality(row)">🧠 人格设置</el-dropdown-item>
|
||||||
|
<el-dropdown-item @click="doDelete(row)" style="color:#f85149">🗑️ 删除用户</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:total="total" :page-sizes="[20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@change="loadUsers" style="margin-top:16px;justify-content:flex-end;display:flex"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Create/Edit Dialog -->
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editUser ? '编辑用户' : '新增虚拟用户'" width="560px">
|
||||||
|
<el-form :model="form" label-width="100px" :rules="rules" ref="formRef">
|
||||||
|
<el-form-item label="昵称">
|
||||||
|
<el-input v-model="form.nickname" placeholder="选填,留空自动生成" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="平台账号" prop="account">
|
||||||
|
<el-input v-model="form.account" placeholder="新闻平台登录账号" :disabled="!!editUser" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="登录密码" :prop="editUser ? '' : 'password'">
|
||||||
|
<el-input v-model="form.password" type="password" :placeholder="editUser ? '不修改请留空' : '登录密码'" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="头像">
|
||||||
|
<div style="display:flex;gap:10px;align-items:flex-start;width:100%">
|
||||||
|
<el-avatar :size="50" :src="form.avatar_url" style="flex-shrink:0">
|
||||||
|
<span>{{ (form.nickname||'?')[0] }}</span>
|
||||||
|
</el-avatar>
|
||||||
|
<div style="flex:1">
|
||||||
|
<el-input v-model="form.avatar_url" placeholder="头像图片链接" style="margin-bottom:6px" />
|
||||||
|
<el-upload v-if="editUser" :show-file-list="false" :before-upload="handleAvatarUpload" accept="image/*">
|
||||||
|
<el-button size="small" type="primary" plain :loading="avatarUploading">📷 上传头像</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="真实姓名">
|
||||||
|
<el-input v-model="form.real_name" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="平台昵称">
|
||||||
|
<el-input v-model="form.nickname" placeholder="同步到目标平台的昵称" />
|
||||||
|
<div style="font-size:11px;color:var(--color-text-muted);margin-top:4px">昵称将同步到目标平台个人中心显示</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="性别">
|
||||||
|
<el-radio-group v-model="form.sex">
|
||||||
|
<el-radio :value="0">未知</el-radio>
|
||||||
|
<el-radio :value="1">男</el-radio>
|
||||||
|
<el-radio :value="2">女</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="个人简介">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="个人简介(可同步到平台)" maxlength="100" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱">
|
||||||
|
<el-input v-model="form.email" placeholder="邮箱地址(可同步到平台)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="活跃度">
|
||||||
|
<el-radio-group v-model="form.activity_level">
|
||||||
|
<el-radio :value="0">低 (3-5次/天)</el-radio>
|
||||||
|
<el-radio :value="1">中 (8-15次/天)</el-radio>
|
||||||
|
<el-radio :value="2">高 (20-30次/天)</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="评论上限">
|
||||||
|
<el-input-number v-model="form.daily_comment_limit" :min="1" :max="100" />
|
||||||
|
<span style="margin-left:8px;font-size:12px;color:var(--color-text-muted)">次/天</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="form.remark" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm" :loading="submitting">
|
||||||
|
{{ editUser ? '保存' : '创建并生成人格' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Personality Dialog -->
|
||||||
|
<el-dialog v-model="personalityVisible" title="AI人格管理" width="560px">
|
||||||
|
<div v-if="currentPersonality" class="personality-panel">
|
||||||
|
<div class="p-header">
|
||||||
|
<span class="p-user">{{ currentUser?.nickname }}</span>
|
||||||
|
<el-button size="small" type="primary" @click="regenPersonality" :loading="regenLoading">
|
||||||
|
<el-icon><RefreshRight /></el-icon> 重新生成
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="p-grid">
|
||||||
|
<div class="p-item"><span class="p-key">性格类型</span>
|
||||||
|
<el-select v-model="currentPersonality.character_type" size="small" style="width:120px">
|
||||||
|
<el-option v-for="c in characters" :key="c" :label="c" :value="c" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="p-item"><span class="p-key">语言风格</span>
|
||||||
|
<el-select v-model="currentPersonality.language_style" size="small" style="width:120px">
|
||||||
|
<el-option v-for="s in langStyles" :key="s" :label="s" :value="s" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="p-item"><span class="p-key">互动倾向</span>
|
||||||
|
<el-select v-model="currentPersonality.interact_tendency" size="small" style="width:120px">
|
||||||
|
<el-option v-for="t in tendencies" :key="t" :label="t" :value="t" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="p-item"><span class="p-key">字数范围</span>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
|
<el-input-number v-model="currentPersonality.word_count_min" :min="10" :max="200" size="small" style="width:90px" />
|
||||||
|
<span>~</span>
|
||||||
|
<el-input-number v-model="currentPersonality.word_count_max" :min="30" :max="500" size="small" style="width:90px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-item" style="margin-top:12px">
|
||||||
|
<span class="p-key">兴趣偏好</span>
|
||||||
|
<el-select v-model="currentPersonality.interest_tags" multiple size="small" style="width:100%">
|
||||||
|
<el-option v-for="t in interestTags" :key="t" :label="t" :value="t" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="p-item" style="margin-top:12px">
|
||||||
|
<span class="p-key">人格描述</span>
|
||||||
|
<el-input v-model="currentPersonality.personality_desc" type="textarea" :rows="3" size="small" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="p-empty">
|
||||||
|
<el-empty description="尚未生成人格">
|
||||||
|
<el-button type="primary" @click="regenPersonality">立即生成</el-button>
|
||||||
|
</el-empty>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="personalityVisible = false">关闭</el-button>
|
||||||
|
<el-button v-if="currentPersonality" type="primary" @click="savePersonality" :loading="savingPersonality">保存人格</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
getUsers, createUser, updateUser, deleteUser, batchUserAction,
|
||||||
|
loginUser, logoutUser, generatePersonality, updatePersonality,
|
||||||
|
downloadTemplate as dlTemplate, importUsers, exportUsers, deduplicateUsers, loginAllUsers, syncAllProfiles,
|
||||||
|
uploadAvatar
|
||||||
|
} from '@/api'
|
||||||
|
|
||||||
|
const users = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const selectedIds = ref([])
|
||||||
|
const filters = reactive({ keyword: '', status: null, is_enabled: null })
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const editUser = ref(null)
|
||||||
|
const formRef = ref()
|
||||||
|
const form = reactive({ nickname: '', account: '', password: '', avatar_url: '', activity_level: 1, daily_comment_limit: 10, remark: '' })
|
||||||
|
const rules = {
|
||||||
|
account: [{ required: true, message: '请输入平台账号(必填)' }],
|
||||||
|
password: [{ required: true, min: 6, message: '密码至少6位(必填)' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const personalityVisible = ref(false)
|
||||||
|
const currentUser = ref(null)
|
||||||
|
const currentPersonality = ref(null)
|
||||||
|
const regenLoading = ref(false)
|
||||||
|
const savingPersonality = ref(false)
|
||||||
|
|
||||||
|
const statusMap = { 0: '未登录', 1: '登录中', 2: '已登录', 3: '登录失效', 4: '封禁' }
|
||||||
|
const statusType = { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'danger' }
|
||||||
|
const activityType = { 0: 'info', 1: 'warning', 2: 'success' }
|
||||||
|
const characters = ['开朗','内敛','毒舌','温和','理性','感性','幽默','严谨']
|
||||||
|
const langStyles = ['严肃','幽默','文艺','吐槽','口语化','学术','简洁','丰富']
|
||||||
|
const tendencies = ['爱评论','爱点赞','爱收藏','潜水','爱转发','爱回复']
|
||||||
|
const interestTags = ['科技','财经','娱乐','体育','政治','文化','教育','医疗','汽车','房产','旅游','美食','军事','国际','环保','农业']
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getUsers({ page: page.value, page_size: pageSize.value, ...filters })
|
||||||
|
users.value = res.data?.items || []
|
||||||
|
total.value = res.data?.total || 0
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
Object.assign(filters, { keyword: '', status: null, is_enabled: null })
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
editUser.value = null
|
||||||
|
Object.assign(form, { nickname: '', account: '', password: '', avatar_url: '', real_name: '', sex: 0, description: '', email: '', activity_level: 1, daily_comment_limit: 10, remark: '' })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row) {
|
||||||
|
editUser.value = row
|
||||||
|
Object.assign(form, { nickname: row.nickname, account: row.account, password: '', avatar_url: row.avatar_url || '', real_name: row.real_name || '', sex: row.sex ?? 0, description: row.description || '', email: row.email || '', activity_level: row.activity_level, daily_comment_limit: row.daily_comment_limit, remark: row.remark || '' })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
await formRef.value.validate()
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (editUser.value) {
|
||||||
|
const data = { ...form }
|
||||||
|
if (!data.password) delete data.password
|
||||||
|
data.sync_to_platform = true // 自动同步到目标平台
|
||||||
|
const res = await updateUser(editUser.value.id, data)
|
||||||
|
if (res.code === 200 || res.code === 206) {
|
||||||
|
// 局部刷新:只更新当前行数据,不刷新整个列表
|
||||||
|
const idx = users.value.findIndex(u => u.id === editUser.value.id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
const updated = { ...users.value[idx], ...{
|
||||||
|
nickname: form.nickname || users.value[idx].nickname,
|
||||||
|
avatar_url: form.avatar_url,
|
||||||
|
real_name: form.real_name,
|
||||||
|
sex: form.sex,
|
||||||
|
activity_level: form.activity_level,
|
||||||
|
daily_comment_limit: form.daily_comment_limit,
|
||||||
|
remark: form.remark,
|
||||||
|
}}
|
||||||
|
users.value.splice(idx, 1, updated)
|
||||||
|
}
|
||||||
|
if (res.code === 206) {
|
||||||
|
ElMessage.warning(res.message || '本地已保存,平台同步失败')
|
||||||
|
} else {
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '更新失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await createUser(form)
|
||||||
|
ElMessage.success('用户创建成功,AI人格已自动生成')
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
} finally { submitting.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete(row) {
|
||||||
|
await ElMessageBox.confirm(`确认删除用户 "${row.nickname}"?`, '确认', { type: 'warning' })
|
||||||
|
await deleteUser(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchAction(action) {
|
||||||
|
if (action === 'delete') {
|
||||||
|
await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 个用户?`, '危险操作', { type: 'warning' })
|
||||||
|
}
|
||||||
|
await batchUserAction({ user_ids: selectedIds.value, action })
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(row) {
|
||||||
|
await updateUser(row.id, { is_enabled: row.is_enabled ? 0 : 1 })
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin(row) {
|
||||||
|
const loading = ElMessage({ message: `正在登录 ${row.nickname}...`, duration: 0 })
|
||||||
|
try {
|
||||||
|
await loginUser(row.id)
|
||||||
|
loading.close()
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
loadUsers()
|
||||||
|
} catch { loading.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout(row) {
|
||||||
|
await logoutUser(row.id)
|
||||||
|
ElMessage.success('已登出')
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPersonality(row) {
|
||||||
|
currentUser.value = row
|
||||||
|
currentPersonality.value = row.personality ? { ...row.personality } : null
|
||||||
|
personalityVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenPersonality() {
|
||||||
|
regenLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await generatePersonality(currentUser.value.id)
|
||||||
|
currentPersonality.value = res.data
|
||||||
|
ElMessage.success('人格重新生成成功')
|
||||||
|
loadUsers()
|
||||||
|
} finally { regenLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePersonality() {
|
||||||
|
savingPersonality.value = true
|
||||||
|
try {
|
||||||
|
await updatePersonality(currentUser.value.id, currentPersonality.value)
|
||||||
|
ElMessage.success('人格已保存')
|
||||||
|
loadUsers()
|
||||||
|
} finally { savingPersonality.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadTemplate() {
|
||||||
|
const res = await dlTemplate()
|
||||||
|
const url = URL.createObjectURL(new Blob([res]))
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = 'import_template.xlsx'; a.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport(file) {
|
||||||
|
const fd = new FormData(); fd.append('file', file)
|
||||||
|
// 显示进度提示
|
||||||
|
const loading = ElMessage({
|
||||||
|
message: '正在导入中,请稍候...',
|
||||||
|
type: 'info',
|
||||||
|
duration: 0,
|
||||||
|
showClose: false,
|
||||||
|
icon: 'Loading'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const res = await importUsers(fd)
|
||||||
|
loading.close()
|
||||||
|
const d = res.data || {}
|
||||||
|
const success = d.success || 0
|
||||||
|
const failed = d.failed || 0
|
||||||
|
if (failed > 0 && success === 0) {
|
||||||
|
ElMessage.error(`导入失败:${failed}条失败,请检查数据格式`)
|
||||||
|
} else if (failed > 0) {
|
||||||
|
ElMessage.warning(`导入完成:成功 ${success} 条,失败 ${failed} 条`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`导入成功:共导入 ${success} 条用户`)
|
||||||
|
}
|
||||||
|
// 导入完成后自动刷新列表
|
||||||
|
await loadUsers()
|
||||||
|
} catch(e) {
|
||||||
|
loading.close()
|
||||||
|
ElMessage.error('导入失败:' + (e.message || '未知错误'))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
const res = await exportUsers()
|
||||||
|
const url = URL.createObjectURL(new Blob([res]))
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = 'users_export.xlsx'; a.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginAllLoading = ref(false)
|
||||||
|
|
||||||
|
const syncLoading = ref(false)
|
||||||
|
const avatarUploading = ref(false)
|
||||||
|
|
||||||
|
async function handleAvatarUpload(file) {
|
||||||
|
if (!editUser.value) return false
|
||||||
|
avatarUploading.value = true
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('sync_to_platform', 'true') // 始终同步到平台
|
||||||
|
const res = await uploadAvatar(editUser.value.id, formData)
|
||||||
|
if (res.code === 200 || res.code === 0) {
|
||||||
|
form.avatar_url = res.data?.avatar_url || ''
|
||||||
|
ElMessage.success('头像上传成功')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '头像上传失败')
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
ElMessage.error('上传失败:' + (e.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
avatarUploading.value = false
|
||||||
|
}
|
||||||
|
return false // 阻止 el-upload 默认上传
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncAll() {
|
||||||
|
syncLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await syncAllProfiles()
|
||||||
|
const d = res.data || {}
|
||||||
|
ElMessage.success(res.message || `同步完成:${d.synced} 个用户`)
|
||||||
|
await loadUsers()
|
||||||
|
} catch(e) {
|
||||||
|
ElMessage.error('同步失败:' + (e.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
syncLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLoginAll() {
|
||||||
|
loginAllLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await loginAllUsers()
|
||||||
|
const d = res.data || {}
|
||||||
|
ElMessage.success(res.message || `登录完成:成功 ${d.success} 个`)
|
||||||
|
await loadUsers()
|
||||||
|
} catch(e) {
|
||||||
|
ElMessage.error('一键登录失败:' + (e.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
loginAllLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeduplicate() {
|
||||||
|
const res = await deduplicateUsers()
|
||||||
|
ElMessage.success(res.message || '去重完成')
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暗色主题行样式:深浅交替,避免白底
|
||||||
|
function rowClassName({ rowIndex }) {
|
||||||
|
return rowIndex % 2 === 0 ? 'row-dark' : 'row-darker'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadUsers(); window.addEventListener('page-refresh', loadUsers) })
|
||||||
|
onUnmounted(() => window.removeEventListener('page-refresh', loadUsers))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.batch-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(88,166,255,0.08); border: 1px solid rgba(88,166,255,0.3); border-radius: 8px; margin-bottom: 10px; }
|
||||||
|
.batch-info { font-size: 13px; color: var(--color-accent); margin-right: 4px; }
|
||||||
|
.data-table { border-radius: 8px; overflow: hidden; }
|
||||||
|
.personality-panel { }
|
||||||
|
.p-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
.p-user { font-size: 15px; font-weight: 600; }
|
||||||
|
.p-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.p-item { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.p-key { font-size: 12px; color: var(--color-text-muted); white-space: nowrap; width: 60px; flex-shrink: 0; }
|
||||||
|
.p-empty { padding: 20px 0; }
|
||||||
|
|
||||||
|
/* 暗色主题表格行样式 */
|
||||||
|
:deep(.row-dark td) { background: var(--color-bg-card) !important; }
|
||||||
|
:deep(.row-darker td) { background: var(--color-bg-secondary) !important; }
|
||||||
|
:deep(.el-table__row:hover td) { background: rgba(88,166,255,0.08) !important; }
|
||||||
|
:deep(.el-table__header-wrapper th) {
|
||||||
|
background: var(--color-bg) !important;
|
||||||
|
color: var(--color-text-muted) !important;
|
||||||
|
border-bottom: 2px solid var(--color-border) !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
:deep(.el-table__body-wrapper) { background: transparent !important; }
|
||||||
|
:deep(.el-table) { background: transparent !important; }
|
||||||
|
</style>
|
||||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': resolve(__dirname, 'src') }
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user